From 3fc987469b19065d8c19e64b2e38a79779c5ed54 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 12:31:58 +0000 Subject: [PATCH 1/7] Move the Generate transform and researched-source parsing into core The app saves generated cases and researched sources the way the website does, so the pure parts of generateCase and autosaveDiscoveredSources now live in packages/core and the website imports them back unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/ai/discovered-sources.ts | 131 +++++++++++ packages/core/src/ai/generate-output.ts | 128 +++++++++++ src/lib/ai/client.ts | 247 +-------------------- 3 files changed, 271 insertions(+), 235 deletions(-) create mode 100644 packages/core/src/ai/discovered-sources.ts create mode 100644 packages/core/src/ai/generate-output.ts diff --git a/packages/core/src/ai/discovered-sources.ts b/packages/core/src/ai/discovered-sources.ts new file mode 100644 index 000000000..6fe981b38 --- /dev/null +++ b/packages/core/src/ai/discovered-sources.ts @@ -0,0 +1,131 @@ +import { validateUrl } from '../validation/url'; + +export interface SourceSemanticMetadata { + relevance: number; + importance: 0 | 1 | 2 | 3; + strength: 0 | 1 | 2 | 3; + evaluatedAt: string; + useCount: number; + caseUseCount: number; + lastUsedAt?: string; + lastUsedInCase?: boolean; +} + +/** A web source discovered during research, with auditable ranking metadata from the edge function. */ +export interface DiscoveredSource { + title: string; + url: string; + content?: string; + sourceType?: string; + qualityScore?: number; + qualityLabel?: 'strong' | 'moderate' | 'weak'; + query?: string; + capturedVia?: string; + semantic?: SourceSemanticMetadata; +} + +/** Feature result plus the researched sources that informed it. */ +export interface ResultWithSources { + result: T; + discoveredSources: DiscoveredSource[]; +} + +export function parseSourceSemanticMetadata(value: unknown): SourceSemanticMetadata | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const semantic = value as Record; + const { relevance, importance, strength, evaluatedAt, useCount, caseUseCount } = semantic; + if (typeof relevance !== 'number' || !Number.isFinite(relevance) || relevance < 0 || relevance > 1 || + typeof importance !== 'number' || !Number.isInteger(importance) || importance < 0 || importance > 3 || + typeof strength !== 'number' || !Number.isInteger(strength) || strength < 0 || strength > 3 || + typeof evaluatedAt !== 'string' || !Number.isFinite(Date.parse(evaluatedAt)) || + typeof useCount !== 'number' || !Number.isInteger(useCount) || useCount < 0 || + typeof caseUseCount !== 'number' || !Number.isInteger(caseUseCount) || caseUseCount < 0 || + (semantic.lastUsedAt !== undefined && typeof semantic.lastUsedAt !== 'string') || + (semantic.lastUsedInCase !== undefined && typeof semantic.lastUsedInCase !== 'boolean')) return undefined; + return { + relevance, + importance: importance as 0 | 1 | 2 | 3, + strength: strength as 0 | 1 | 2 | 3, + evaluatedAt, + useCount, + caseUseCount, + ...(typeof semantic.lastUsedAt === 'string' ? { lastUsedAt: semantic.lastUsedAt } : {}), + ...(typeof semantic.lastUsedInCase === 'boolean' ? { lastUsedInCase: semantic.lastUsedInCase } : {}), + }; +} + +/** + * Parses the discovered-sources payload at the trust boundary: only + * well-formed entries with a title and URL survive; anything else is dropped. + */ +export function parseDiscoveredSources(value: unknown): DiscoveredSource[] { + if (!Array.isArray(value)) return []; + + return value.flatMap((item): DiscoveredSource[] => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return []; + + const candidate: Record = {}; + for (const [key, fieldValue] of Object.entries(item)) candidate[key] = fieldValue; + + const title = typeof candidate.title === 'string' ? candidate.title.trim() : ''; + const url = typeof candidate.url === 'string' ? candidate.url.trim() : ''; + const normalizedUrl = validateUrl(url); + if (!title || !normalizedUrl) return []; + + const source: DiscoveredSource = { title, url: normalizedUrl }; + if (typeof candidate.content === 'string') source.content = candidate.content; + + if (typeof candidate.sourceType === 'string' && candidate.sourceType.trim()) { + source.sourceType = candidate.sourceType.trim(); + } + + if (typeof candidate.qualityScore === 'number' && Number.isFinite(candidate.qualityScore) && + candidate.qualityScore >= 0 && candidate.qualityScore <= 100) { + source.qualityScore = candidate.qualityScore; + } + + if (candidate.qualityLabel === 'strong' || candidate.qualityLabel === 'moderate' || candidate.qualityLabel === 'weak') { + source.qualityLabel = candidate.qualityLabel; + } + + if (typeof candidate.query === 'string' && candidate.query.trim()) { + source.query = candidate.query.trim(); + } + + const semantic = parseSourceSemanticMetadata(candidate.semantic); + if (semantic) source.semantic = semantic; + + return [source]; + }); +} + +/** The Library folder researched sources are saved to. */ +export const AUTO_SAVED_FOLDER = 'Auto-saved'; + +/** The researched sources worth keeping in the Library: weak or off-topic ones are dropped unless the case used them. */ +export function retainedDiscoveredSources(sources: DiscoveredSource[]): DiscoveredSource[] { + return sources.filter((source) => !source.semantic || + (source.qualityLabel !== 'weak' && source.semantic.relevance >= 0.45) || + source.semantic.lastUsedInCase === true); +} + +/** The Library row an auto-saved source becomes, apart from its user and debate. */ +export function autoSavedSourceRow(source: DiscoveredSource, capturedVia: string) { + return { + title: source.title, + content: source.content ?? '', + type: 'url' as const, + origin: source.url, + folder: AUTO_SAVED_FOLDER, + isActive: source.qualityLabel === 'strong' || source.qualityLabel === 'moderate', + metadata: { + url: source.url, + ...(source.sourceType !== undefined ? { sourceType: source.sourceType } : {}), + ...(source.qualityScore !== undefined ? { qualityScore: source.qualityScore } : {}), + ...(source.qualityLabel !== undefined ? { qualityLabel: source.qualityLabel } : {}), + ...(source.query !== undefined ? { query: source.query } : {}), + ...(source.semantic !== undefined ? { semantic: source.semantic } : {}), + capturedVia, + }, + }; +} diff --git a/packages/core/src/ai/generate-output.ts b/packages/core/src/ai/generate-output.ts new file mode 100644 index 000000000..aa45be2fd --- /dev/null +++ b/packages/core/src/ai/generate-output.ts @@ -0,0 +1,128 @@ +import type { GenerateInput, GenerateOutput } from './schemas/generate'; + +function cleanGeneratedString(value: string) { + return value + .replace(/[—–]/g, ', ') + .replace(/\b(Magnitude|Probability|Timeframe):\s*/gi, '') + .replace(/\s{2,}/g, ' ') + .trim(); +} + +export function cleanGeneratedOutput(value: T): T { + if (typeof value === 'string') return cleanGeneratedString(value) as T; + if (Array.isArray(value)) return value.map(cleanGeneratedOutput) as T; + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, cleanGeneratedOutput(child)]) + ) as T; + } + return value; +} + +/** + * Transforms AI generate response from various formats to the expected GenerateOutput schema. + * This handles cases where the AI returns a different structure than requested. + */ +export function transformGenerateResponse(response: Record): GenerateOutput { + // If already in correct format, return as-is (with exordio preserved) + if (response.caseStructure && response.contentions && response.predictedOpponentCase && response.counters && response.metadata) { + // Ensure exordio is properly structured if present + const result = response as unknown as GenerateOutput; + if (response.exordio && typeof response.exordio === 'object') { + const ex = response.exordio as Record; + result.exordio = { + hook: String(ex.hook || ''), + transition: String(ex.transition || ''), + }; + } + return cleanGeneratedOutput(result); + } + + // Transform from wrong format to correct format + const wrongFormatContentions = response.contentions as Array<{ + title?: string; + claim?: string; + points?: string[]; + evidence?: string[] | string; + analysis?: string; + reasoning?: string; + impact?: string; + }> || []; + + const transformedContentions = wrongFormatContentions.map((c, i) => ({ + id: String(i + 1), + claim: c.claim || c.title || 'Argument', + reasoning: c.reasoning || c.analysis || (Array.isArray(c.points) ? c.points.join(' ') : ''), + evidence: Array.isArray(c.evidence) ? c.evidence.join(' ') : (c.evidence || ''), + needsSource: false, + sourceQuery: '', + impact: c.impact || c.analysis || '', + })); + + // Transform rebuttals to counters + const rebuttals = (response.rebuttals as Array<{ attack?: string; response?: string }>) || []; + const counters = rebuttals.map(r => ({ + targetArgument: r.attack || '', + counterpoint: r.response || '', + rebuttal: r.response || '', + })); + + // Transform potentialWeaknesses to predictedOpponentCase + const weaknesses = (response.potentialWeaknesses as string[]) || []; + + const result: GenerateOutput = { + caseStructure: { + thesis: (response.thesis as string) || (response.title as string) || '', + framework: 'General framework', + keyDefinitions: [], + burdenOfProof: 'We must prove that ' + ((response.thesis as string) || '').slice(0, 50), + }, + contentions: transformedContentions.length > 0 ? transformedContentions : [{ + id: '1', + claim: 'Main argument', + reasoning: 'Reasoning here', + evidence: 'Evidence here', + needsSource: false, + sourceQuery: '', + impact: 'Impact here', + }], + predictedOpponentCase: { + likelyArguments: weaknesses.slice(0, 3), + strategicWeaknesses: weaknesses, + keyClashes: ['Main clash point'], + }, + counters: counters.length > 0 ? counters : [{ + targetArgument: 'Opponent argument', + counterpoint: 'Our counter', + rebuttal: 'Rebuttal text', + }], + metadata: { + side: (response.side as 'pro' | 'con') || 'pro', + format: 'custom', + generatedAt: new Date().toISOString(), + }, + }; + + // Preserve exordio if present in the original response + if (response.exordio && typeof response.exordio === 'object') { + const ex = response.exordio as Record; + result.exordio = { + hook: String(ex.hook || ''), + transition: String(ex.transition || ''), + }; + } + + return cleanGeneratedOutput(result); +} + +export function expectedContentionCount(length: GenerateInput['length']): number { + switch (length) { + case 'standard': + return 3; + case 'comprehensive': + return 4; + case 'brief': + default: + return 2; + } +} diff --git a/src/lib/ai/client.ts b/src/lib/ai/client.ts index e75b969ad..5956f5d32 100644 --- a/src/lib/ai/client.ts +++ b/src/lib/ai/client.ts @@ -19,15 +19,18 @@ import { activeDebateId, useDebateStore } from '../stores/debate-store'; import { parseDebateContextUsage, useDebateContextStore } from '../stores/debate-context-store'; import { currentDebateContextPlan } from '../debate/context-state'; import { requestSide, type DebateContextRequest } from '../../../supabase/functions/_shared/debate-context'; +import { type SourceAssessmentUpdate } from '../stores/session-store'; +import { cleanGeneratedOutput, expectedContentionCount, transformGenerateResponse } from '@debatelab/core/ai/generate-output'; import { - type SourceAssessmentUpdate, - type SourceProvenance, - type SourceSemanticMetadata, -} from '../stores/session-store'; + autoSavedSourceRow, + parseDiscoveredSources, + retainedDiscoveredSources, + type DiscoveredSource, + type ResultWithSources, +} from '@debatelab/core/ai/discovered-sources'; import { getActiveAccessToken, getActiveUserId, invokeAuthenticatedFunction, supabase } from '@/lib/supabase/client'; import { toast } from '@/hooks/use-toast'; import { captureException, captureProductEvent } from '@/lib/observability'; -import { validateUrl } from '@/lib/validation/url'; import { isAppLanguage, type AppLanguage } from '@/lib/languages'; import i18n from '@/i18n'; import { @@ -44,6 +47,8 @@ import { type CrossfireTurnSignal, } from '@/lib/crossfire/schemas'; import type { WorkspaceToolContext } from '@/lib/workspace/tool-context'; + +export type { DiscoveredSource, ResultWithSources }; import { parseVoicePayload, type VoicePayload } from '@/lib/voice/payload'; // Request timeout in milliseconds (200 seconds) @@ -156,88 +161,6 @@ function waitForAbortable(operation: () => PromiseLike, signal: AbortSigna }); } -/** A web source discovered during research, with auditable ranking metadata from the edge function. */ -export interface DiscoveredSource extends Omit { - title: string; - url: string; - content?: string; -} - -/** Feature result plus the researched sources that informed it. */ -export interface ResultWithSources { - result: T; - discoveredSources: DiscoveredSource[]; -} - -function parseSourceSemanticMetadata(value: unknown): SourceSemanticMetadata | undefined { - if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; - const semantic = value as Record; - const { relevance, importance, strength, evaluatedAt, useCount, caseUseCount } = semantic; - if (typeof relevance !== 'number' || !Number.isFinite(relevance) || relevance < 0 || relevance > 1 || - typeof importance !== 'number' || !Number.isInteger(importance) || importance < 0 || importance > 3 || - typeof strength !== 'number' || !Number.isInteger(strength) || strength < 0 || strength > 3 || - typeof evaluatedAt !== 'string' || !Number.isFinite(Date.parse(evaluatedAt)) || - typeof useCount !== 'number' || !Number.isInteger(useCount) || useCount < 0 || - typeof caseUseCount !== 'number' || !Number.isInteger(caseUseCount) || caseUseCount < 0 || - (semantic.lastUsedAt !== undefined && typeof semantic.lastUsedAt !== 'string') || - (semantic.lastUsedInCase !== undefined && typeof semantic.lastUsedInCase !== 'boolean')) return undefined; - return { - relevance, - importance: importance as 0 | 1 | 2 | 3, - strength: strength as 0 | 1 | 2 | 3, - evaluatedAt, - useCount, - caseUseCount, - ...(typeof semantic.lastUsedAt === 'string' ? { lastUsedAt: semantic.lastUsedAt } : {}), - ...(typeof semantic.lastUsedInCase === 'boolean' ? { lastUsedInCase: semantic.lastUsedInCase } : {}), - }; -} - -/** - * Parses the discovered-sources payload at the trust boundary: only - * well-formed entries with a title and URL survive; anything else is dropped. - */ -function parseDiscoveredSources(value: unknown): DiscoveredSource[] { - if (!Array.isArray(value)) return []; - - return value.flatMap((item): DiscoveredSource[] => { - if (!item || typeof item !== 'object' || Array.isArray(item)) return []; - - const candidate: Record = {}; - for (const [key, fieldValue] of Object.entries(item)) candidate[key] = fieldValue; - - const title = typeof candidate.title === 'string' ? candidate.title.trim() : ''; - const url = typeof candidate.url === 'string' ? candidate.url.trim() : ''; - const normalizedUrl = validateUrl(url); - if (!title || !normalizedUrl) return []; - - const source: DiscoveredSource = { title, url: normalizedUrl }; - if (typeof candidate.content === 'string') source.content = candidate.content; - - if (typeof candidate.sourceType === 'string' && candidate.sourceType.trim()) { - source.sourceType = candidate.sourceType.trim(); - } - - if (typeof candidate.qualityScore === 'number' && Number.isFinite(candidate.qualityScore) && - candidate.qualityScore >= 0 && candidate.qualityScore <= 100) { - source.qualityScore = candidate.qualityScore; - } - - if (candidate.qualityLabel === 'strong' || candidate.qualityLabel === 'moderate' || candidate.qualityLabel === 'weak') { - source.qualityLabel = candidate.qualityLabel; - } - - if (typeof candidate.query === 'string' && candidate.query.trim()) { - source.query = candidate.query.trim(); - } - - const semantic = parseSourceSemanticMetadata(candidate.semantic); - if (semantic) source.semantic = semantic; - - return [source]; - }); -} - function parseSourceAssessments(value: unknown): SourceAssessmentUpdate[] { if (!Array.isArray(value)) return []; return value.slice(0, 10).flatMap((item): SourceAssessmentUpdate[] => { @@ -995,121 +918,6 @@ function isProviderErrorCode(code?: string): boolean { return !!code && /quota|billing|api[_-]?key|provider|insufficient|overloaded|rate/i.test(code); } -function cleanGeneratedString(value: string) { - return value - .replace(/[—–]/g, ', ') - .replace(/\b(Magnitude|Probability|Timeframe):\s*/gi, '') - .replace(/\s{2,}/g, ' ') - .trim(); -} - -function cleanGeneratedOutput(value: T): T { - if (typeof value === 'string') return cleanGeneratedString(value) as T; - if (Array.isArray(value)) return value.map(cleanGeneratedOutput) as T; - if (value && typeof value === 'object') { - return Object.fromEntries( - Object.entries(value).map(([key, child]) => [key, cleanGeneratedOutput(child)]) - ) as T; - } - return value; -} - -/** - * Transforms AI generate response from various formats to the expected GenerateOutput schema. - * This handles cases where the AI returns a different structure than requested. - */ -function transformGenerateResponse(response: Record): GenerateOutput { - // If already in correct format, return as-is (with exordio preserved) - if (response.caseStructure && response.contentions && response.predictedOpponentCase && response.counters && response.metadata) { - // Ensure exordio is properly structured if present - const result = response as unknown as GenerateOutput; - if (response.exordio && typeof response.exordio === 'object') { - const ex = response.exordio as Record; - result.exordio = { - hook: String(ex.hook || ''), - transition: String(ex.transition || ''), - }; - } - return cleanGeneratedOutput(result); - } - - // Transform from wrong format to correct format - const wrongFormatContentions = response.contentions as Array<{ - title?: string; - claim?: string; - points?: string[]; - evidence?: string[] | string; - analysis?: string; - reasoning?: string; - impact?: string; - }> || []; - - const transformedContentions = wrongFormatContentions.map((c, i) => ({ - id: String(i + 1), - claim: c.claim || c.title || 'Argument', - reasoning: c.reasoning || c.analysis || (Array.isArray(c.points) ? c.points.join(' ') : ''), - evidence: Array.isArray(c.evidence) ? c.evidence.join(' ') : (c.evidence || ''), - needsSource: false, - sourceQuery: '', - impact: c.impact || c.analysis || '', - })); - - // Transform rebuttals to counters - const rebuttals = (response.rebuttals as Array<{ attack?: string; response?: string }>) || []; - const counters = rebuttals.map(r => ({ - targetArgument: r.attack || '', - counterpoint: r.response || '', - rebuttal: r.response || '', - })); - - // Transform potentialWeaknesses to predictedOpponentCase - const weaknesses = (response.potentialWeaknesses as string[]) || []; - - const result: GenerateOutput = { - caseStructure: { - thesis: (response.thesis as string) || (response.title as string) || '', - framework: 'General framework', - keyDefinitions: [], - burdenOfProof: 'We must prove that ' + ((response.thesis as string) || '').slice(0, 50), - }, - contentions: transformedContentions.length > 0 ? transformedContentions : [{ - id: '1', - claim: 'Main argument', - reasoning: 'Reasoning here', - evidence: 'Evidence here', - needsSource: false, - sourceQuery: '', - impact: 'Impact here', - }], - predictedOpponentCase: { - likelyArguments: weaknesses.slice(0, 3), - strategicWeaknesses: weaknesses, - keyClashes: ['Main clash point'], - }, - counters: counters.length > 0 ? counters : [{ - targetArgument: 'Opponent argument', - counterpoint: 'Our counter', - rebuttal: 'Rebuttal text', - }], - metadata: { - side: (response.side as 'pro' | 'con') || 'pro', - format: 'custom', - generatedAt: new Date().toISOString(), - }, - }; - - // Preserve exordio if present in the original response - if (response.exordio && typeof response.exordio === 'object') { - const ex = response.exordio as Record; - result.exordio = { - hook: String(ex.hook || ''), - transition: String(ex.transition || ''), - }; - } - - return cleanGeneratedOutput(result); -} - /** * Transforms AI brainstorm response to ensure all required fields exist. * This handles cases where the AI returns incomplete structures. @@ -1239,32 +1047,13 @@ async function autosaveDiscoveredSources(sources: DiscoveredSource[], capturedVi const targetDebateId = debateId !== undefined ? debateId : activeDebateId(); // Every source belongs to one debate, so one-off research is not kept. if (!targetDebateId) return; - const retainedSources = sources.filter((source) => !source.semantic || - (source.qualityLabel !== 'weak' && source.semantic.relevance >= 0.45) || - source.semantic.lastUsedInCase === true); + const retainedSources = retainedDiscoveredSources(sources); if (retainedSources.length === 0) return; const session = useSessionStore.getState(); for (const source of retainedSources) { try { - await session.addSource({ - title: source.title, - content: source.content ?? '', - type: 'url', - origin: source.url, - folder: 'Auto-saved', - debateId: targetDebateId, - isActive: source.qualityLabel === 'strong' || source.qualityLabel === 'moderate', - metadata: { - url: source.url, - ...(source.sourceType !== undefined ? { sourceType: source.sourceType } : {}), - ...(source.qualityScore !== undefined ? { qualityScore: source.qualityScore } : {}), - ...(source.qualityLabel !== undefined ? { qualityLabel: source.qualityLabel } : {}), - ...(source.query !== undefined ? { query: source.query } : {}), - ...(source.semantic !== undefined ? { semantic: source.semantic } : {}), - capturedVia, - }, - }); + await session.addSource({ ...autoSavedSourceRow(source, capturedVia), debateId: targetDebateId }); } catch { toast({ title: i18n.t('aiClient.sourceSaveFailed'), @@ -1276,18 +1065,6 @@ async function autosaveDiscoveredSources(sources: DiscoveredSource[], capturedVi console.log(`[AI Client] Saved ${retainedSources.length} sources to library`); } -function expectedContentionCount(length: GenerateInput['length']): number { - switch (length) { - case 'standard': - return 3; - case 'comprehensive': - return 4; - case 'brief': - default: - return 2; - } -} - export async function generateCase(input: GenerateInput, config: ProviderConfig, workspaceContext?: WorkspaceToolContext, debateId?: string | null): Promise> { const inputWithLanguage = withOutputLanguage(input as unknown as Record); const response = await callSupabaseAI>('generate', inputWithLanguage, config, { From 9c40cdfa4c3f542d1d56391f4d4a9728d2908d20 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 12:36:07 +0000 Subject: [PATCH 2/7] Share Ask's request helper, model config and context builder Generate and Attack send the same requests as Ask: the same headers, 200 s timeout, abort before the session read and error kinds. The POST moves to ai-request.ts, the model reader takes a feature, and the context builder takes the feature and side the website's requestSide picks. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 2 +- apps/mobile/src/app/ask.tsx | 34 ++--- apps/mobile/src/lib/ai-request.ts | 127 +++++++++++++++++ apps/mobile/src/lib/ask.ts | 130 ++---------------- .../{ask-context.ts => context-request.ts} | 28 ++-- .../lib/{ask-config.ts => feature-config.ts} | 22 +-- 6 files changed, 180 insertions(+), 163 deletions(-) create mode 100644 apps/mobile/src/lib/ai-request.ts rename apps/mobile/src/lib/{ask-context.ts => context-request.ts} (86%) rename apps/mobile/src/lib/{ask-config.ts => feature-config.ts} (78%) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 06e080bc9..5747988e6 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -20,7 +20,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - `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), `ask-config.ts` reads the Ask model from `user_settings.feature_configs`, `ask-context.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. +- 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. - 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. - `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. diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx index 3ba8333a9..07d174829 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -12,19 +12,10 @@ import { Chip } from '@/components/chip'; import { Icon } from '@/components/icon'; import type { IconName } from '@/components/icon-paths'; import { Text } from '@/components/text'; -import { - askOnce, - AskError, - canStream, - isAbortError, - isFinalAskError, - isMessageWithinBudget, - streamAsk, - type AskErrorKind, - type AskOptions, -} from '@/lib/ask'; -import { fetchAskConfig, type AskConfig } from '@/lib/ask-config'; -import { buildAskDebateContext } from '@/lib/ask-context'; +import { AIError, isAbortError, type AIErrorKind } from '@/lib/ai-request'; +import { askOnce, canStream, isFinalAskError, isMessageWithinBudget, streamAsk, type AskOptions } from '@/lib/ask'; +import { buildDebateContext } from '@/lib/context-request'; +import { fetchFeatureConfig, type FeatureConfig } from '@/lib/feature-config'; import { useAuth } from '@/lib/auth'; import { ChatSaveError, fetchChat, saveChat, type ChatMessage, type ChatRecord } from '@/lib/chats'; import { useDebates } from '@/lib/debate-context'; @@ -47,8 +38,8 @@ const OPTION_CHIPS: { key: keyof AskOptions; label: string; icon: IconName }[] = const THINKING = ['askChat.thinking', 'askChat.researching', 'askChat.analyzing', 'askChat.generatingResponse', 'askChat.almostThere']; -const PLAN_ERRORS = new Set(['trialExpired', 'limitReached', 'modelRestricted']); -const RETRYABLE_ERRORS = new Set(['network', 'timeout', 'failed', 'rateLimit']); +const PLAN_ERRORS = new Set(['trialExpired', 'limitReached', 'modelRestricted']); +const RETRYABLE_ERRORS = new Set(['network', 'timeout', 'failed', 'rateLimit']); let localId = 0; const nextId = () => `local-${Date.now()}-${(localId += 1)}`; @@ -71,7 +62,7 @@ export default function AskScreen() { const [record, setRecordState] = useState(NEW_CHAT); const [options, setOptions] = useState(NO_OPTIONS); const [input, setInput] = useState(''); - const [error, setError] = useState<{ kind: AskErrorKind; question: string } | null>(null); + const [error, setError] = useState<{ kind: AIErrorKind; question: string } | null>(null); const [notice, setNotice] = useState(''); const [historyOpen, setHistoryOpen] = useState(false); const [library, setLibrary] = useState([]); @@ -79,7 +70,7 @@ export default function AskScreen() { const scroll = useRef(null); const requests = useRef(0); const abort = useRef(null); - const config = useRef | null>(null); + const config = useRef | null>(null); // The conversation the saved record belongs to; replacing it drops late writes. const conversation = useRef(0); const recordRef = useRef(record); @@ -93,7 +84,7 @@ export default function AskScreen() { }; const askConfig = () => { - if (!config.current && userId) config.current = fetchAskConfig(userId); + if (!config.current && userId) config.current = fetchFeatureConfig(userId, 'ask'); return config.current!; }; @@ -194,7 +185,8 @@ export default function AskScreen() { const debateId = open.chatId ? open.debateId : activeDebateRef.current; const [askSettings, debateContext] = await Promise.all([ askConfig(), - debateId ? buildAskDebateContext(userId, debateId, open.chatId) : Promise.resolve(undefined), + // Ask prepares no side, so every saved result matches it. + debateId ? buildDebateContext(userId, debateId, 'ask', 'both', open.chatId) : Promise.resolve(undefined), ]); if (!isCurrent()) return; const askRequest = { @@ -260,8 +252,8 @@ export default function AskScreen() { } catch (cause) { if (!isCurrent() || isAbortError(cause)) return; setStreaming(null); - const kind = cause instanceof AskError ? cause.kind : 'failed'; - if (!(cause instanceof AskError)) console.warn('Ask failed:', cause instanceof Error ? cause.message : cause); + const kind = cause instanceof AIError ? cause.kind : 'failed'; + if (!(cause instanceof AIError)) console.warn('Ask failed:', cause instanceof Error ? cause.message : cause); // A missing debate was deleted elsewhere; reload the list so the app stops using it. if (kind === 'debateMissing') void refresh(); setError({ kind, question: message }); diff --git a/apps/mobile/src/lib/ai-request.ts b/apps/mobile/src/lib/ai-request.ts new file mode 100644 index 000000000..5af13d4b0 --- /dev/null +++ b/apps/mobile/src/lib/ai-request.ts @@ -0,0 +1,127 @@ +import { fetch } from 'expo/fetch'; + +import { supabase } from './supabase'; +import { supabaseProject } from './supabase-config'; + +/** + * One request to an AI Edge Function, sent the way the website's AI client + * (src/lib/ai/client.ts) sends it: the same headers, the 200 s timeout, the + * caller's abort, and its errors sorted into the kinds the screens explain. + */ + +export const REQUEST_TIMEOUT_MS = 200 * 1000; +const MAX_REQUEST_BODY_BYTES = 190_000; +export const PROVIDER = 'openrouter'; + +export type AIErrorKind = + | 'auth' + | 'trialExpired' + | 'limitReached' + | 'modelRestricted' + | 'rateLimit' + | 'network' + | 'timeout' + | 'tooLarge' + | 'debateMissing' + | 'failed'; + +export class AIError extends Error { + constructor(readonly kind: AIErrorKind, message: string) { + super(message); + this.name = 'AIError'; + } +} + +export function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError'; +} + +export function abortError(): Error { + const error = new Error('Aborted'); + error.name = 'AbortError'; + return error; +} + +export function utf8Length(value: string): number { + let bytes = 0; + for (const char of value) { + const code = char.codePointAt(0)!; + bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4; + } + return bytes; +} + +async function accessToken(): Promise { + const { data } = await supabase.auth.getSession(); + const token = data.session?.access_token; + if (!token) throw new AIError('auth', 'Please sign in to use AI features'); + return token; +} + +export function errorFrom(status: number | undefined, payload: Record | null, fallback: string): AIError { + const message = typeof payload?.error === 'string' ? payload.error : typeof payload?.message === 'string' ? payload.message : fallback; + const code = typeof payload?.code === 'string' ? payload.code : undefined; + if (status === 401) return new AIError('auth', message); + if (code === 'upgrade_required') return new AIError('modelRestricted', message); + if (status === 402 || code === 'trial_expired' || code === 'limit_reached') { + return new AIError(code === 'trial_expired' ? 'trialExpired' : 'limitReached', message); + } + if (status === 403) return /model|upgrade/i.test(message) ? new AIError('modelRestricted', message) : new AIError('auth', message); + if (status === 404) return new AIError('debateMissing', message); + if (status === 429 || /rate limit|limit exceeded/i.test(message)) return new AIError('rateLimit', message); + return new AIError('failed', message); +} + +export async function payloadOf(response: { json: () => Promise }): Promise | null> { + const payload: unknown = await response.json().catch((): null => null); + return payload && typeof payload === 'object' && !Array.isArray(payload) ? (payload as Record) : null; +} + +export type FunctionResponse = Awaited>; + +/** One POST with the website's headers, a 200 s timeout and the caller's abort. */ +export async function postFunction(path: string, body: Record, signal: AbortSignal | undefined, run: (response: FunctionResponse) => Promise): Promise { + // The deadline and the caller's abort start before the session read, so + // leaving the screen while the token refreshes never sends the request. + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + const abortForCaller = () => controller.abort(); + signal?.addEventListener('abort', abortForCaller); + if (signal?.aborted) controller.abort(); + try { + if (controller.signal.aborted) throw abortError(); + const token = await accessToken(); + if (controller.signal.aborted) throw abortError(); + const json = JSON.stringify(body); + if (utf8Length(json) > MAX_REQUEST_BODY_BYTES) throw new AIError('tooLarge', 'Request is too large'); + const response = await fetch(`${supabaseProject.url}/functions/v1/${path}`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, apikey: supabaseProject.anonKey, 'Content-Type': 'application/json' }, + body: json, + signal: controller.signal, + }).catch((cause: unknown) => { + if (controller.signal.aborted) throw cause; + throw new AIError('network', cause instanceof Error ? cause.message : 'Network request failed'); + }); + if (!response.ok) throw errorFrom(response.status, await payloadOf(response), `Request failed (${response.status})`); + return await run(response); + } catch (error) { + if (signal?.aborted) throw abortError(); + if (controller.signal.aborted) throw new AIError('timeout', 'The request took too long'); + throw error; + } finally { + clearTimeout(timeout); + signal?.removeEventListener('abort', abortForCaller); + } +} + +/** + * The `output` of a whole answer from the ai function. A 200 response can + * still carry an error, as the website's callSupabaseAI handles. + */ +export async function functionOutput(response: FunctionResponse): Promise<{ output: unknown; data: Record }> { + const data = await payloadOf(response); + if (typeof data?.error === 'string') throw errorFrom(undefined, data, data.error); + if (!data?.output) throw new AIError('failed', 'The answer was missing'); + return { output: data.output, data }; +} diff --git a/apps/mobile/src/lib/ask.ts b/apps/mobile/src/lib/ask.ts index b1174cdc3..93738033c 100644 --- a/apps/mobile/src/lib/ask.ts +++ b/apps/mobile/src/lib/ask.ts @@ -1,11 +1,9 @@ import { AskOutputSchema, type AskOutput } from '@debatelab/core/ai/schemas/ask'; import { consumeSSEChunk, type ParsedSSEEvent } from '@debatelab/core/ai/sse'; import type { DebateContextRequest } from '@debatelab/core/contract/debate-context'; -import { fetch } from 'expo/fetch'; -import type { AskConfig } from './ask-config'; -import { supabase } from './supabase'; -import { supabaseProject } from './supabase-config'; +import { abortError, AIError, errorFrom, functionOutput, postFunction, PROVIDER, utf8Length } from './ai-request'; +import type { FeatureConfig } from './feature-config'; /** * Ask requests, sent the way the website sends them (src/lib/ai/streaming.ts @@ -15,13 +13,10 @@ import { supabaseProject } from './supabase-config'; * function, as on the website. */ -export const REQUEST_TIMEOUT_MS = 200 * 1000; const MAX_HISTORY_ITEMS = 8; const MAX_HISTORY_CONTENT_BYTES = 6_000; const MAX_HISTORY_TOTAL_BYTES = 48_000; const MAX_MESSAGE_BYTES = 80_000; -const MAX_REQUEST_BODY_BYTES = 190_000; -const PROVIDER = 'openrouter'; export interface AskOptions { citationMode: boolean; @@ -39,44 +34,15 @@ export interface AskRequest { message: string; history: HistoryMessage[]; options: AskOptions; - config: AskConfig; + config: FeatureConfig; language: string; /** Absent for one-off work. */ debateContext?: DebateContextRequest; } -export type AskErrorKind = - | 'auth' - | 'trialExpired' - | 'limitReached' - | 'modelRestricted' - | 'rateLimit' - | 'network' - | 'timeout' - | 'tooLarge' - | 'debateMissing' - | 'failed'; - -export class AskError extends Error { - constructor(readonly kind: AskErrorKind, message: string) { - super(message); - this.name = 'AskError'; - } -} - /** The answer kinds that fail the same way however they are asked, so retrying without streaming cannot help. */ export function isFinalAskError(error: unknown): boolean { - return error instanceof AskError && error.kind !== 'failed' && error.kind !== 'network'; -} - -export function isAbortError(error: unknown): boolean { - return error instanceof Error && error.name === 'AbortError'; -} - -function abortError(): Error { - const error = new Error('Aborted'); - error.name = 'AbortError'; - return error; + return error instanceof AIError && error.kind !== 'failed' && error.kind !== 'network'; } /** Streaming serves plain questions only, as on the website (isAskStreamingSupported). */ @@ -84,15 +50,6 @@ export function canStream(options: AskOptions): boolean { return !(options.citationMode || options.webSearch || options.prosConsMode || options.argumentMapMode); } -function utf8Length(value: string): number { - let bytes = 0; - for (const char of value) { - const code = char.codePointAt(0)!; - bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4; - } - return bytes; -} - /** Cuts at a whole character, like the website's truncateUtf8. */ function truncateUtf8(value: string, maxBytes: number): string { let bytes = 0; @@ -122,7 +79,7 @@ export function boundHistory(history: HistoryMessage[]): HistoryMessage[] { } function requestInput(request: AskRequest) { - if (!isMessageWithinBudget(request.message)) throw new AskError('tooLarge', 'Message is too large'); + if (!isMessageWithinBudget(request.message)) throw new AIError('tooLarge', 'Message is too large'); return { message: request.message, history: boundHistory(request.history), @@ -135,78 +92,16 @@ function requestInput(request: AskRequest) { }; } -async function accessToken(): Promise { - const { data } = await supabase.auth.getSession(); - const token = data.session?.access_token; - if (!token) throw new AskError('auth', 'Please sign in to use AI features'); - return token; -} - -function errorFrom(status: number | undefined, payload: Record | null, fallback: string): AskError { - const message = typeof payload?.error === 'string' ? payload.error : typeof payload?.message === 'string' ? payload.message : fallback; - const code = typeof payload?.code === 'string' ? payload.code : undefined; - if (status === 401) return new AskError('auth', message); - if (code === 'upgrade_required') return new AskError('modelRestricted', message); - if (status === 402 || code === 'trial_expired' || code === 'limit_reached') { - return new AskError(code === 'trial_expired' ? 'trialExpired' : 'limitReached', message); - } - if (status === 403) return /model|upgrade/i.test(message) ? new AskError('modelRestricted', message) : new AskError('auth', message); - if (status === 404) return new AskError('debateMissing', message); - if (status === 429 || /rate limit|limit exceeded/i.test(message)) return new AskError('rateLimit', message); - return new AskError('failed', message); -} - -async function payloadOf(response: { json: () => Promise }): Promise | null> { - const payload: unknown = await response.json().catch((): null => null); - return payload && typeof payload === 'object' && !Array.isArray(payload) ? (payload as Record) : null; -} - /** The stream's final event names the model that answered; the website records it on the output. */ function parseOutput(output: unknown, resolvedModel?: string): AskOutput { const enriched = resolvedModel && output && typeof output === 'object' && !Array.isArray(output) ? { ...output, model: resolvedModel, provider: PROVIDER } : output; const parsed = AskOutputSchema.safeParse(enriched); - if (!parsed.success) throw new AskError('failed', 'The answer did not match the expected shape'); + if (!parsed.success) throw new AIError('failed', 'The answer did not match the expected shape'); return parsed.data; } -/** One POST with the website's headers, a 200 s timeout and the caller's abort. */ -async function post(path: string, body: Record, signal: AbortSignal | undefined, run: (response: Awaited>) => Promise): Promise { - // The deadline and the caller's abort start before the session read, so - // leaving Ask while the token refreshes never sends the request. - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - const abortForCaller = () => controller.abort(); - signal?.addEventListener('abort', abortForCaller); - if (signal?.aborted) controller.abort(); - try { - if (controller.signal.aborted) throw abortError(); - const token = await accessToken(); - if (controller.signal.aborted) throw abortError(); - const json = JSON.stringify(body); - if (utf8Length(json) > MAX_REQUEST_BODY_BYTES) throw new AskError('tooLarge', 'Request is too large'); - const response = await fetch(`${supabaseProject.url}/functions/v1/${path}`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}`, apikey: supabaseProject.anonKey, 'Content-Type': 'application/json' }, - body: json, - signal: controller.signal, - }).catch((cause: unknown) => { - if (controller.signal.aborted) throw cause; - throw new AskError('network', cause instanceof Error ? cause.message : 'Network request failed'); - }); - if (!response.ok) throw errorFrom(response.status, await payloadOf(response), `Request failed (${response.status})`); - return await run(response); - } catch (error) { - if (signal?.aborted) throw abortError(); - if (controller.signal.aborted) throw new AskError('timeout', 'The request took too long'); - throw error; - } finally { - clearTimeout(timeout); - signal?.removeEventListener('abort', abortForCaller); - } -} - /** Streams an answer from ai-stream, calling onDelta with each piece of text. */ export function streamAsk(request: AskRequest, handlers: { onDelta: (text: string) => void; signal?: AbortSignal }): Promise { const body = { @@ -218,8 +113,8 @@ export function streamAsk(request: AskRequest, handlers: { onDelta: (text: strin temperature: request.config.temperature, reasoningEffort: request.config.reasoningEffort, }; - return post('ai-stream', body, handlers.signal, async (response) => { - if (!response.body) throw new AskError('failed', 'The answer did not stream'); + return postFunction('ai-stream', body, handlers.signal, async (response) => { + if (!response.body) throw new AIError('failed', 'The answer did not stream'); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; @@ -256,7 +151,7 @@ export function streamAsk(request: AskRequest, handlers: { onDelta: (text: strin consume(parsed.events); } consume(consumeSSEChunk('', `${buffer}\n\n`).events); - if (!output) throw new AskError('failed', 'Streaming request ended before a final response was received'); + if (!output) throw new AIError('failed', 'Streaming request ended before a final response was received'); return output; } finally { handlers.signal?.removeEventListener('abort', cancel); @@ -278,10 +173,5 @@ export function askOnce(request: AskRequest, signal?: AbortSignal): Promise { - const data = await payloadOf(response); - if (typeof data?.error === 'string') throw errorFrom(undefined, data, data.error); - if (!data?.output) throw new AskError('failed', 'The answer was missing'); - return parseOutput(data.output); - }); + return postFunction('ai', body, signal, async (response) => parseOutput((await functionOutput(response)).output)); } diff --git a/apps/mobile/src/lib/ask-context.ts b/apps/mobile/src/lib/context-request.ts similarity index 86% rename from apps/mobile/src/lib/ask-context.ts rename to apps/mobile/src/lib/context-request.ts index 7f7d85af3..bbbf061e4 100644 --- a/apps/mobile/src/lib/ask-context.ts +++ b/apps/mobile/src/lib/context-request.ts @@ -5,6 +5,7 @@ import { MAX_CONTEXT_OUTPUTS, MAX_CONTEXT_SOURCES, selectDefaultOutputs, + type DebateContextFeature, type DebateContextOutputKind, type DebateContextRequest, type DebateContextSide, @@ -15,9 +16,9 @@ import { import { supabase } from './supabase'; /** - * The Current Debate material an Ask request sends: IDs only, picked the way - * the website's planDebateContext (src/lib/debate/context.ts) picks them with - * no Review choices. Pins come from debate_context_pins, as on the website; + * The Current Debate material a request sends: IDs only, picked the way the + * website's planDebateContext (src/lib/debate/context.ts) picks them with no + * Review choices. Pins come from debate_context_pins, as on the website; * if they cannot be read, the automatic selection is used. The server loads * and checks the text. */ @@ -35,9 +36,6 @@ const RESULT_KINDS = Object.keys(RESULT_TABLES) as ResultKind[]; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -// Ask prepares no side, so every saved result matches it. -const ASK_SIDE: DebateContextSide = 'both'; - function sideOf(value: unknown): DebateContextSide | null { return value === 'pro' || value === 'con' || value === 'both' || value === 'neutral' ? value : null; } @@ -73,8 +71,18 @@ async function outputData(userId: string, ref: DebateOutputRef): Promise { +/** + * `side` is the side the request prepares, as the website's requestSide + * picks it. `excludeChatId` is the chat open in Ask: it is already the + * conversation. + */ +export async function buildDebateContext( + userId: string, + debateId: string, + feature: DebateContextFeature, + side: DebateContextSide, + excludeChatId: string | null = null, +): Promise { const [sources, pins, sparring, ...results] = await Promise.all([ supabase.from('sources').select('id,is_active').eq('user_id', userId).eq('debate_id', debateId).order('created_at', { ascending: false }), supabase.from('debate_context_pins').select('item_kind,item_id').eq('user_id', userId).eq('debate_id', debateId), @@ -106,14 +114,14 @@ export async function buildAskDebateContext(userId: string, debateId: string, ex candidates.push({ kind: 'sparring', id: row.id, side: sideOf(row.user_side), createdAt: new Date(row.updated_at).getTime(), pinned: pinnedKeys.has(`sparring:${row.id}`) }); } - const sent = selectDefaultOutputs(candidates, 'ask', ASK_SIDE).slice(0, MAX_CONTEXT_OUTPUTS); + const sent = selectDefaultOutputs(candidates, feature, side).slice(0, MAX_CONTEXT_OUTPUTS); // Results with nothing usable yet, such as an unjudged round, are not sent. const digests = await Promise.all(sent.map(async (ref) => ({ ...ref, digest: digestOutput(ref.kind, await outputData(userId, ref)) }))); const empty = new Set(boundOutputDigests(digests).empty.map((item) => `${item.kind}:${item.id}`)); return { debateId, - side: ASK_SIDE, + side, sourceIds, outputs: sent.filter((ref) => !empty.has(`${ref.kind}:${ref.id}`)), }; diff --git a/apps/mobile/src/lib/ask-config.ts b/apps/mobile/src/lib/feature-config.ts similarity index 78% rename from apps/mobile/src/lib/ask-config.ts rename to apps/mobile/src/lib/feature-config.ts index 9395dd03a..ad3bbb2b5 100644 --- a/apps/mobile/src/lib/ask-config.ts +++ b/apps/mobile/src/lib/feature-config.ts @@ -1,4 +1,4 @@ -import { getDefaultFeatureConfigs } from '@debatelab/core/ai/feature-configs'; +import { getDefaultFeatureConfigs, type FeatureTab } from '@debatelab/core/ai/feature-configs'; import { canAccessProModels } from '@debatelab/core/ai/pro-access'; import { AUTO_FAST, @@ -14,19 +14,19 @@ import { import { supabase } from './supabase'; /** - * The Ask model and its settings, picked exactly as the website's settings + * A tool's model and its settings, picked exactly as the website's settings * store (src/lib/stores/settings-store.ts) picks them from * user_settings.feature_configs. The app only reads them: the model picker * and the migration that rewrites old configs stay on the website. */ -export interface AskConfig { +export interface FeatureConfig { model: string; temperature: number; reasoningEffort: ReasoningEffort; } -function defaultConfig(model: string = DEFAULT_MODEL): AskConfig { - const { temperature, reasoningEffort } = getDefaultFeatureConfigs(model).ask; +function defaultConfig(feature: FeatureTab, model: string = DEFAULT_MODEL): FeatureConfig { + const { temperature, reasoningEffort } = getDefaultFeatureConfigs(model)[feature]; return { model, temperature, reasoningEffort: reasoningEffort ?? 'low' }; } @@ -57,7 +57,7 @@ function normalizeModelId(value: unknown, fallback: string): string { return LEGACY_MODEL_SUCCESSORS[model] ?? DEFAULT_MODEL; } -function normalizeConfig(value: unknown, fallback: AskConfig, allowProModels: boolean): AskConfig { +function normalizeConfig(value: unknown, fallback: FeatureConfig, allowProModels: boolean): FeatureConfig { const candidate = asRecord(value); const model = normalizeModelId(candidate?.model, fallback.model); return { @@ -73,13 +73,13 @@ function normalizeConfig(value: unknown, fallback: AskConfig, allowProModels: bo }; } -export async function fetchAskConfig(userId: string): Promise { +export async function fetchFeatureConfig(userId: string, feature: FeatureTab): Promise { const { data, error } = await supabase .from('user_settings') .select('feature_configs, subscription_tier, grace_period_ends_at') .eq('user_id', userId) .maybeSingle(); - if (error || !data) return defaultConfig(); + if (error || !data) return defaultConfig(feature); // Null when it cannot be told, as on the website, which then keeps its defaults. const allowProModels = await canAccessProModels(data, async () => { const { data: rows, error: billingError } = await supabase @@ -88,7 +88,7 @@ export async function fetchAskConfig(userId: string): Promise { .eq('user_id', userId); return billingError ? null : rows ?? []; }); - if (allowProModels === null) return defaultConfig(); - const fallback = defaultConfig(allowProModels ? DEFAULT_PRO_MODEL : DEFAULT_MODEL); - return normalizeConfig(asRecord(data.feature_configs)?.ask, fallback, allowProModels); + if (allowProModels === null) return defaultConfig(feature); + const fallback = defaultConfig(feature, allowProModels ? DEFAULT_PRO_MODEL : DEFAULT_MODEL); + return normalizeConfig(asRecord(data.feature_configs)?.[feature], fallback, allowProModels); } From ef80016579bb14d704b4d5dcb28092a697f913c8 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 12:57:28 +0000 Subject: [PATCH 3/7] Add the Prepare tab, Generate, Attack and Export to the app The Prepare tab lists the tools and the Current Debate's saved Prepare results. Generate and Attack send the website's requests, keep researched sources in the Auto-saved folder and save to cases and attacks as the website does. Results open as documents after mock 03, with collapsible arguments, citation chips, a share button, a menu with Discuss, Save, the auto-saved sources undo and Delete, and pinned next steps. Export makes a PDF with expo-print and copies the result as text. Word and Google Docs export are not in the app yet. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 3 +- apps/mobile/package.json | 4 + apps/mobile/src/app/(tabs)/(debate)/index.tsx | 72 +--- .../src/app/(tabs)/(practice)/practice.tsx | 10 +- .../app/(tabs)/(prepare)/coming/[tool].tsx | 13 + .../src/app/(tabs)/(prepare)/prepare.tsx | 130 ++++++- apps/mobile/src/app/_layout.tsx | 5 + apps/mobile/src/app/ask.tsx | 13 +- apps/mobile/src/app/attack/[id].tsx | 102 +++++ apps/mobile/src/app/attack/index.tsx | 241 ++++++++++++ apps/mobile/src/app/case/[id].tsx | 110 ++++++ apps/mobile/src/app/generate.tsx | 290 ++++++++++++++ apps/mobile/src/components/action-menu.tsx | 63 ++++ .../mobile/src/components/action-menu.web.tsx | 51 +++ apps/mobile/src/components/ask-answer.tsx | 30 +- .../mobile/src/components/attack-document.tsx | 165 ++++++++ apps/mobile/src/components/buttons.tsx | 53 ++- apps/mobile/src/components/case-document.tsx | 241 ++++++++++++ apps/mobile/src/components/citation-target.ts | 32 ++ .../mobile/src/components/document-header.tsx | 57 +++ .../src/components/document-header.web.tsx | 59 +++ .../mobile/src/components/document-status.tsx | 35 ++ apps/mobile/src/components/document.tsx | 354 ++++++++++++++++++ apps/mobile/src/components/form-rows.tsx | 125 +++++++ apps/mobile/src/components/icon-paths.ts | 6 + apps/mobile/src/components/markdown-view.tsx | 19 +- apps/mobile/src/components/practice-tools.ts | 2 +- apps/mobile/src/components/prepare-tools.ts | 11 + apps/mobile/src/components/tool-form.tsx | 120 ++++++ apps/mobile/src/components/work-row.tsx | 79 ++++ apps/mobile/src/lib/back-label.ts | 7 + apps/mobile/src/lib/confirm.ts | 15 + apps/mobile/src/lib/confirm.web.ts | 8 + apps/mobile/src/lib/debates.ts | 48 ++- apps/mobile/src/lib/export-html.ts | 126 +++++++ apps/mobile/src/lib/export.ts | 26 ++ apps/mobile/src/lib/export.web.ts | 29 ++ apps/mobile/src/lib/motion-gate.ts | 30 ++ apps/mobile/src/lib/motion-gate.web.ts | 11 + apps/mobile/src/lib/motion.ts | 40 ++ apps/mobile/src/lib/open-results.ts | 45 +++ apps/mobile/src/lib/result-text.ts | 93 +++++ apps/mobile/src/lib/results.ts | 209 +++++++++++ apps/mobile/src/lib/sources.ts | 38 +- apps/mobile/src/lib/use-result-document.ts | 199 ++++++++++ apps/mobile/src/theme/colors.ts | 6 + package-lock.json | 41 ++ src/locales/en.json | 35 ++ src/locales/es.json | 35 ++ src/locales/hu.json | 35 ++ 50 files changed, 3443 insertions(+), 128 deletions(-) create mode 100644 apps/mobile/src/app/(tabs)/(prepare)/coming/[tool].tsx create mode 100644 apps/mobile/src/app/attack/[id].tsx create mode 100644 apps/mobile/src/app/attack/index.tsx create mode 100644 apps/mobile/src/app/case/[id].tsx create mode 100644 apps/mobile/src/app/generate.tsx create mode 100644 apps/mobile/src/components/action-menu.tsx create mode 100644 apps/mobile/src/components/action-menu.web.tsx create mode 100644 apps/mobile/src/components/attack-document.tsx create mode 100644 apps/mobile/src/components/case-document.tsx create mode 100644 apps/mobile/src/components/citation-target.ts create mode 100644 apps/mobile/src/components/document-header.tsx create mode 100644 apps/mobile/src/components/document-header.web.tsx create mode 100644 apps/mobile/src/components/document-status.tsx create mode 100644 apps/mobile/src/components/document.tsx create mode 100644 apps/mobile/src/components/form-rows.tsx create mode 100644 apps/mobile/src/components/prepare-tools.ts create mode 100644 apps/mobile/src/components/tool-form.tsx create mode 100644 apps/mobile/src/components/work-row.tsx create mode 100644 apps/mobile/src/lib/back-label.ts create mode 100644 apps/mobile/src/lib/export-html.ts create mode 100644 apps/mobile/src/lib/export.ts create mode 100644 apps/mobile/src/lib/export.web.ts create mode 100644 apps/mobile/src/lib/motion-gate.ts create mode 100644 apps/mobile/src/lib/motion-gate.web.ts create mode 100644 apps/mobile/src/lib/motion.ts create mode 100644 apps/mobile/src/lib/open-results.ts create mode 100644 apps/mobile/src/lib/result-text.ts create mode 100644 apps/mobile/src/lib/results.ts create mode 100644 apps/mobile/src/lib/use-result-document.ts diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 5747988e6..c4bf61388 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -16,11 +16,12 @@ 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`, full screen above the tabs. All are declared in the root `_layout.tsx`. +- `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/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`) 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. - 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. - `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. diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 5cb31b52e..c1ef574cc 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -16,14 +16,18 @@ "aes-js": "^3.1.2", "date-fns": "^3.6.0", "expo": "~57.0.27", + "expo-clipboard": "~57.0.2", "expo-constants": "~57.0.21", "expo-crypto": "~57.0.3", + "expo-file-system": "~57.0.7", "expo-font": "~57.0.4", "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.12", "expo-localization": "~57.0.2", + "expo-print": "~57.0.2", "expo-router": "~57.0.25", "expo-secure-store": "~57.0.4", + "expo-sharing": "~57.0.22", "expo-splash-screen": "~57.0.9", "expo-status-bar": "~57.0.1", "expo-system-ui": "~57.0.4", diff --git a/apps/mobile/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index ff1759905..d96f3b6bc 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -8,9 +8,11 @@ import { MainActionButton, TextButton } from '@/components/buttons'; import { Card, IconTile } from '@/components/card'; import { Icon } from '@/components/icon'; import type { IconName } from '@/components/icon-paths'; +import { PREPARE_TOOLS } from '@/components/prepare-tools'; import { Screen } from '@/components/screen'; import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; +import { WorkRow, workHref } from '@/components/work-row'; import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; import { @@ -18,36 +20,13 @@ import { fetchRecentWork, type DebateCounts, type DebateItem, - type DebateItemKind, type DebateSide, } from '@/lib/debates'; -import { formatWhen } from '@/lib/dates'; import { mainActionGradient, radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; const RECENT_LIMIT = 3; -// The Prepare tools on the website's Current Debate page. Each opens the Prepare tab until its screen lands. -const PREPARE_TOOLS: { key: string; icon: IconName }[] = [ - { key: 'generate', icon: 'sparkles' }, - { key: 'brainstorm', icon: 'bulb' }, - { key: 'factoids', icon: 'book' }, - { key: 'flowchart', icon: 'branch' }, -]; - -const KIND_ICONS: Record = { - case: 'sparkles', - attack: 'swords', - brainstorm: 'bulb', - factoids: 'book', - chat: 'chat', - flowchart: 'branch', - crossfire: 'target', - sparring: 'swords', -}; - -const PRACTICE_KINDS = new Set(['attack', 'crossfire', 'sparring']); - /** Home: the Current Debate, as in mock 01. */ export default function DebateScreen() { const { t } = useTranslation(); @@ -222,7 +201,15 @@ function DebateDetails({ debateId }: { debateId: string }) { {PREPARE_TOOLS.map((tool) => ( - open('/prepare')} /> + + tool.key === 'generate' + ? router.push({ pathname: '/generate', params: { from: 'debate' } }) + : router.push({ pathname: '/coming/[tool]', params: { tool: tool.key } })} + /> ))} @@ -247,12 +234,7 @@ function DebateDetails({ debateId }: { debateId: string }) { ) : ( {items.map((item, index) => ( - open(PRACTICE_KINDS.has(item.kind) ? '/practice' : '/prepare')} - /> + router.navigate(workHref(item, 'debate'))} /> ))} )} @@ -319,32 +301,6 @@ function ToolTile({ icon, label, onPress }: { icon: IconName; label: string; onP ); } -function RecentRow({ item, first, onPress }: { item: DebateItem; first: boolean; onPress: () => void }) { - const { t } = useTranslation(); - const { colors } = useTheme(); - return ( - [ - styles.row, - !first && { borderTopWidth: 1, borderTopColor: colors.line }, - pressed && styles.pressed, - ]}> - - - - {item.title || t(`debate.kinds.${item.kind}`)} - - - {t('mobile.debate.itemMeta', { kind: t(`debate.kinds.${item.kind}`), when: formatWhen(item.createdAt) })} - - - - - ); -} - const styles = StyleSheet.create({ loading: { marginTop: 32 }, body: { fontSize: 16, lineHeight: 22 }, @@ -412,8 +368,4 @@ const styles = StyleSheet.create({ }, tileLabel: { flex: 1, fontSize: 15.5 }, list: { padding: 0, gap: 0 }, - row: { flexDirection: 'row', alignItems: 'center', gap: 13, paddingVertical: 12, paddingHorizontal: 14 }, - rowText: { flex: 1, gap: 2 }, - rowTitle: { fontSize: 16 }, - rowMeta: { fontSize: 14 }, }); diff --git a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx index 1ea88571b..0cd759c39 100644 --- a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx +++ b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx @@ -10,7 +10,7 @@ import { Screen } from '@/components/screen'; import { Text } from '@/components/text'; import { useTheme } from '@/theme/use-theme'; -/** The Practice tools. Ask opens full screen; the others say they are on their way. */ +/** The Practice tools. Attack and Ask open full screen; the others say they are on their way. */ export default function PracticeScreen() { const { t } = useTranslation(); const router = useRouter(); @@ -23,7 +23,13 @@ export default function PracticeScreen() { (tool.key === 'ask' ? router.push('/ask') : router.push({ pathname: '/soon/[tool]', params: { tool: tool.key } }))} + onPress={() => + tool.key === 'ask' + ? router.push('/ask') + : tool.key === 'attack' + ? router.push({ pathname: '/attack', params: { from: 'practice' } }) + : router.push({ pathname: '/soon/[tool]', params: { tool: tool.key } })} + testID={`practice-tool-${tool.key}`} style={({ pressed }) => [styles.row, index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> diff --git a/apps/mobile/src/app/(tabs)/(prepare)/coming/[tool].tsx b/apps/mobile/src/app/(tabs)/(prepare)/coming/[tool].tsx new file mode 100644 index 000000000..f89f641d2 --- /dev/null +++ b/apps/mobile/src/app/(tabs)/(prepare)/coming/[tool].tsx @@ -0,0 +1,13 @@ +import { useLocalSearchParams } from 'expo-router'; +import { useTranslation } from 'react-i18next'; + +import { PlaceholderScreen } from '@/components/placeholder-screen'; +import { PREPARE_TOOLS } from '@/components/prepare-tools'; + +/** A Prepare tool that is not in the app yet. */ +export default function PrepareToolScreen() { + const { t } = useTranslation(); + const { tool } = useLocalSearchParams<{ tool: string }>(); + const entry = PREPARE_TOOLS.find((item) => item.key === tool); + return ; +} diff --git a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx index f85db33d5..69ddf8c99 100644 --- a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx +++ b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx @@ -1,5 +1,129 @@ -import { PlaceholderScreen } from '@/components/placeholder-screen'; +import { useFocusEffect, useRouter } from 'expo-router'; +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; -export default function Screen() { - return ; +import { TextButton } from '@/components/buttons'; +import { Card, IconTile } from '@/components/card'; +import { Icon } from '@/components/icon'; +import { PREPARE_TOOLS, type PrepareTool } from '@/components/prepare-tools'; +import { Screen } from '@/components/screen'; +import { ScreenTitle } from '@/components/screen-title'; +import { Text } from '@/components/text'; +import { WorkRow, workHref } from '@/components/work-row'; +import { useAuth } from '@/lib/auth'; +import { useDebates } from '@/lib/debate-context'; +import { fetchPrepareWork, type DebateItem } from '@/lib/debates'; +import { useTheme } from '@/theme/use-theme'; + +const SAVED_LIMIT = 30; + +/** The Prepare tools, then the Current Debate's saved Prepare results, newest first. */ +export default function PrepareScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const { colors } = useTheme(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const { current, oneOff } = useDebates(); + const debateId = current?.id ?? null; + const [items, setItems] = useState(null); + const [failed, setFailed] = useState(false); + + const openTool = (tool: PrepareTool) => + tool === 'generate' ? router.push({ pathname: '/generate', params: { from: 'prepare' } }) : router.push({ pathname: '/coming/[tool]', params: { tool } }); + + const load = useCallback(async (isCurrent: () => boolean) => { + if (!userId || !debateId) return; + try { + const next = await fetchPrepareWork(userId, debateId, SAVED_LIMIT); + if (!isCurrent()) return; + setItems(next); + setFailed(false); + } catch (cause) { + if (!isCurrent()) return; + console.warn('Could not load saved results:', cause instanceof Error ? cause.message : cause); + setFailed(true); + } + }, [userId, debateId]); + + // Reload on focus, so a case generated a moment ago, or on the website, shows up. + useFocusEffect( + useCallback(() => { + let active = true; + void load(() => active); + return () => { + active = false; + }; + }, [load]), + ); + + return ( + + + + {PREPARE_TOOLS.map((tool, index) => ( + openTool(tool.key)} + testID={`prepare-tool-${tool.key}`} + style={({ pressed }) => [styles.row, index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> + + + + {t(`nav.${tool.key}`)} + + + {t(`navDesc.${tool.key}`)} + + + + + ))} + + + + {t('mobile.prepare.saved')} + + {oneOff || !debateId ? ( + + {oneOff ? t('debate.oneOffStrip') : t('debate.noCurrentStrip')} + + ) : failed ? ( + + + {t('mobile.debate.loadFailed')} + + void load(() => true)} /> + + ) : items === null ? ( + + ) : items.length === 0 ? ( + + + {t('debate.noWork')} + + + ) : ( + + {items.map((item, index) => ( + router.push(workHref(item, 'prepare'))} /> + ))} + + )} + + ); } + +const styles = StyleSheet.create({ + list: { padding: 0, gap: 0 }, + row: { flexDirection: 'row', alignItems: 'center', gap: 13, paddingVertical: 12, paddingHorizontal: 14 }, + rowText: { flex: 1, gap: 2 }, + rowTitle: { fontSize: 16 }, + rowMeta: { fontSize: 14 }, + pressed: { opacity: 0.7 }, + section: { fontSize: 18.5, lineHeight: 24, letterSpacing: -0.2, marginTop: 8, marginHorizontal: 2 }, + body: { fontSize: 15.5, lineHeight: 21 }, + loading: { marginTop: 16 }, +}); diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 6691de4f6..289e641ee 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -81,6 +81,11 @@ function Root() { {/* Full screen above the tabs, as in mock 07. */} + {/* Generate, Attack and their documents, full screen above the tabs, as in mock 03. */} + + + + diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx index 07d174829..1a650c8b0 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -1,5 +1,5 @@ import type { AskOutput } from '@debatelab/core/ai/schemas/ask'; -import { useRouter } from 'expo-router'; +import { useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, TextInput, View } from 'react-native'; @@ -48,6 +48,7 @@ const nextId = () => `local-${Date.now()}-${(localId += 1)}`; export default function AskScreen() { const { t } = useTranslation(); const router = useRouter(); + const { prompt } = useLocalSearchParams<{ prompt?: string }>(); const insets = useSafeAreaInsets(); const { colors } = useTheme(); const { session } = useAuth(); @@ -266,6 +267,16 @@ export default function AskScreen() { } }; + // Discuss on a case or an attack opens Ask with the website's message and sends it once. + const prompted = useRef(false); + useEffect(() => { + if (prompted.current || !prompt?.trim() || !userId) return; + prompted.current = true; + void send(prompt, []); + // send reads refs and state setters; it runs once per opening. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [prompt, userId]); + // Retrying replaces the unanswered question instead of asking it twice. const retry = (question: string) => { const last = messages[messages.length - 1]; diff --git a/apps/mobile/src/app/attack/[id].tsx b/apps/mobile/src/app/attack/[id].tsx new file mode 100644 index 000000000..41ca5cf50 --- /dev/null +++ b/apps/mobile/src/app/attack/[id].tsx @@ -0,0 +1,102 @@ +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ActionMenu } from '@/components/action-menu'; +import { AttackDocument } from '@/components/attack-document'; +import { PrimaryButton, SecondaryButton } from '@/components/buttons'; +import { DocumentScreen, DocumentTitle } from '@/components/document'; +import { DocumentHeader, type HeaderAction } from '@/components/document-header'; +import { DocumentStatus } from '@/components/document-status'; +import { backLabel } from '@/lib/back-label'; +import { formatWhen } from '@/lib/dates'; +import { copyText, sharePdf, shareText } from '@/lib/export'; +import { attackExportFileName, attackExportHtml } from '@/lib/export-html'; +import { attackToText, discussAttackMessage } from '@/lib/result-text'; +import { isCompleteAttack } from '@/lib/results'; +import { useResultDocument } from '@/lib/use-result-document'; + +/** + * An attack analysis in the same document style as a case. Its pinned next + * step is the website's: Discuss, which takes the critique to Ask. + */ +export default function AttackResultScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const { id, from, notice: arrival, failed } = useLocalSearchParams<{ id: string; from?: string; notice?: string; failed?: string }>(); + const doc = useResultDocument('attack', id); + const [exportOpen, setExportOpen] = useState(false); + const { setNotice } = doc; + + useEffect(() => { + if (arrival === 'recordFailed') setNotice(t('debate.recordFailed')); + if (arrival === 'sourcesFailed') setNotice(t('mobile.results.sourcesFailed', { count: Number(failed) || 1 })); + }, [arrival, failed, setNotice, t]); + + const result = doc.state.status === 'ready' ? doc.state.result : null; + const output = result && isCompleteAttack(result.output) ? result.output : null; + const back = backLabel(t, from, 'practice'); + + if (!result || !output) { + return ( + }> + + + ); + } + + // The saved title is the summary cut at 80 characters; the summary already opens the document. + const title = t('export.caseAttack'); + const text = () => attackToText(output, t); + const run = (work: () => Promise) => { + work().catch((cause: unknown) => { + console.warn('Export failed:', cause instanceof Error ? cause.message : cause); + setNotice(t('mobile.results.exportFailed')); + }); + }; + const discuss = () => router.push({ pathname: '/ask', params: { prompt: discussAttackMessage(output) } }); + + const actions: HeaderAction[] = [ + ...(doc.autoSaved.length > 0 + ? [{ key: 'undo-sources', label: t('autoSavedSources.undo'), symbol: 'arrow.uturn.backward' as const, icon: 'history' as const, onPress: () => void doc.undoAutoSaved() }] + : []), + ...(result.savedId ? [{ key: 'delete', label: t('common.delete'), symbol: 'trash' as const, icon: 'trash' as const, destructive: true, onPress: () => void doc.remove() }] : []), + ]; + + return ( + run(() => shareText(title, text()))} actions={actions} />} + actions={ + <> + + setExportOpen(true)} disabled={doc.busy} testID="export-button" /> + + }> + + + setExportOpen(false)} + onSelect={(choice) => { + if (choice === 'pdf') run(() => sharePdf(attackExportHtml(output, t), attackExportFileName(output), title)); + if (choice === 'copy') run(async () => { + await copyText(text()); + setNotice(t('generateResults.copiedToClipboard')); + }); + }} + /> + + ); +} diff --git a/apps/mobile/src/app/attack/index.tsx b/apps/mobile/src/app/attack/index.tsx new file mode 100644 index 000000000..194a01c7f --- /dev/null +++ b/apps/mobile/src/app/attack/index.tsx @@ -0,0 +1,241 @@ +import type { AttackInput } from '@debatelab/core/ai/schemas/attack'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { MainActionButton } from '@/components/buttons'; +import { DocumentScreen } from '@/components/document'; +import { DocumentHeader } from '@/components/document-header'; +import { Field } from '@/components/field'; +import { FormGroup, SelectRow, SwitchRow } from '@/components/form-rows'; +import { Text } from '@/components/text'; +import { ToolError, ToolProgress, ToolTitle, WorkingIn } from '@/components/tool-form'; +import { AIError, isAbortError, type AIErrorKind } from '@/lib/ai-request'; +import { useAuth } from '@/lib/auth'; +import { backLabel } from '@/lib/back-label'; +import { buildDebateContext } from '@/lib/context-request'; +import { useDebates } from '@/lib/debate-context'; +import { fetchFeatureConfig } from '@/lib/feature-config'; +import { keepOpenResult, openResult } from '@/lib/open-results'; +import { caseToText } from '@/lib/result-text'; +import { attackCase, autosaveSources, fetchResult, isCompleteCase, recordResult } from '@/lib/results'; +import { useSettings } from '@/lib/settings'; +import { useTheme } from '@/theme/use-theme'; + +// The website's defaults (AttackForm). The last form stays for the session, as on the website. +const DEFAULT_FORM: AttackInput = { + caseText: '', + aggressiveness: 'ruthless', + judgeParadigm: 'logic', + detectFallacies: true, + analyzeWeighing: true, +}; +let lastForm: AttackInput = DEFAULT_FORM; + +const PROGRESS = ['attackForm.loadingMsg1', 'attackForm.loadingMsg2', 'attackForm.loadingMsg3', 'attackForm.loadingMsg4', 'attackForm.loadingMsg5']; + +/** + * Attack, as the website's AttackPage. Opened from a case, the case's text + * fills the form, as the website passes a case in through the Attack form. + */ +export default function AttackScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const { from, caseKey } = useLocalSearchParams<{ from?: string; caseKey?: string }>(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const { activeDebateId, refresh } = useDebates(); + const { outputLanguage } = useSettings(); + const { colors } = useTheme(); + const [form, setFormState] = useState(lastForm); + const [textError, setTextError] = useState(false); + const [phase, setPhase] = useState<'form' | 'attacking'>('form'); + const [error, setError] = useState(null); + const requests = useRef(0); + const abort = useRef(null); + const live = useRef(true); + + const setForm = useCallback((patch: Partial) => + setFormState((previous) => { + const next = { ...previous, ...patch }; + lastForm = next; + return next; + }), []); + + const abortActive = useCallback(() => { + requests.current += 1; + abort.current?.abort(); + abort.current = null; + }, []); + + useEffect(() => () => { + live.current = false; + abortActive(); + }, [abortActive]); + + // A case opened here fills the form with its text. + useEffect(() => { + if (!caseKey) return; + const open = openResult(caseKey, 'case'); + if (open && isCompleteCase(open.output)) { + setForm({ caseText: caseToText(open.output) }); + return; + } + if (!userId || caseKey.startsWith('local-')) return; + let active = true; + fetchResult(userId, 'case', caseKey) + .then((row) => { + if (active && row && isCompleteCase(row.output)) setForm({ caseText: caseToText(row.output) }); + }) + .catch((cause: unknown) => console.warn('Could not load the case to attack:', cause instanceof Error ? cause.message : cause)); + return () => { + active = false; + }; + }, [caseKey, userId, setForm]); + + const submit = async () => { + if (!userId) return; + const input = { ...form, caseText: form.caseText.trim() }; + if (!input.caseText) { + setTextError(true); + return; + } + const forUser = userId; + // Attack critiques pasted text, not a motion, so it follows the Current Debate directly. + const debateId = activeDebateId; + abortActive(); + const request = requests.current; + const isCurrent = () => requests.current === request && live.current; + const controller = new AbortController(); + abort.current = controller; + setError(null); + setPhase('attacking'); + try { + const [config, debateContext] = await Promise.all([ + fetchFeatureConfig(forUser, 'attack'), + debateId ? buildDebateContext(forUser, debateId, 'attack', 'both') : Promise.resolve(undefined), + ]); + if (!isCurrent()) return; + const { result, discoveredSources } = await attackCase(input, { config, language: outputLanguage, debateContext, signal: controller.signal }); + if (!isCurrent()) return; + const sources = await autosaveSources(forUser, discoveredSources, 'attack', debateId); + if (!isCurrent()) return; + const title = result.overallAssessment?.summary?.slice(0, 80) || input.caseText.slice(0, 80); + let savedId: string | null = null; + let recordFailed = false; + if (debateId) { + try { + savedId = await recordResult(forUser, 'attack', { title, input, output: result, debateId, side: 'neutral' }); + } catch (cause) { + console.warn('Could not save the attack to the debate:', cause instanceof Error ? cause.message : cause); + recordFailed = true; + } + } + if (!isCurrent()) return; + const key = keepOpenResult({ kind: 'attack', savedId, title, side: 'neutral', debateId, createdAt: Date.now(), input, output: result, sources: discoveredSources }); + const notice = recordFailed ? 'recordFailed' : sources.failed > 0 ? 'sourcesFailed' : undefined; + router.replace({ + pathname: '/attack/[id]', + params: { + id: key, + from: caseKey ? 'case' : from ?? 'practice', + ...(notice ? { notice } : {}), + ...(sources.failed > 0 ? { failed: String(sources.failed) } : {}), + }, + }); + } catch (cause) { + if (!isCurrent() || isAbortError(cause)) return; + const kind = cause instanceof AIError ? cause.kind : 'failed'; + if (!(cause instanceof AIError)) console.warn('Attack failed:', cause instanceof Error ? cause.message : cause); + if (kind === 'debateMissing') void refresh(); + setError(kind); + setPhase('form'); + } finally { + if (requests.current === request) abort.current = null; + } + }; + + const attacking = phase === 'attacking'; + + return ( + } + actions={attacking ? undefined : void submit()} />}> + + + {attacking ? ( + t(key))} + onCancel={() => { + abortActive(); + setPhase('form'); + }} + /> + ) : ( + <> + { + setForm({ caseText }); + if (caseText.trim()) setTextError(false); + }} + placeholder={t('attackForm.argumentPlaceholder')} + multiline + invalid={textError} + style={styles.caseText} + testID="attack-case-text" + /> + {textError ? ( + + {t('mobile.attack.caseRequired')} + + ) : null} + + + first + label={t('attackForm.intensity')} + value={form.aggressiveness} + onChange={(aggressiveness) => setForm({ aggressiveness })} + options={[ + { value: 'mild', label: t('attackForm.intensityMild') }, + { value: 'moderate', label: t('attackForm.intensityModerate') }, + { value: 'ruthless', label: t('attackForm.intensityRuthless') }, + ]} + /> + + label={t('attackForm.paradigm')} + value={form.judgeParadigm} + onChange={(judgeParadigm) => setForm({ judgeParadigm })} + options={[ + { value: 'logic', label: t('attackForm.paradigmLogic') }, + { value: 'values', label: t('attackForm.paradigmValues') }, + { value: 'policy', label: t('attackForm.paradigmPolicy') }, + ]} + /> + setForm({ detectFallacies })} + /> + setForm({ analyzeWeighing })} + /> + + {error ? void submit()} /> : null} + + )} + + ); +} + +const styles = StyleSheet.create({ + caseText: { minHeight: 200, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top' }, + invalid: { fontSize: 14, marginTop: -6, marginLeft: 4 }, +}); diff --git a/apps/mobile/src/app/case/[id].tsx b/apps/mobile/src/app/case/[id].tsx new file mode 100644 index 000000000..eddf39b75 --- /dev/null +++ b/apps/mobile/src/app/case/[id].tsx @@ -0,0 +1,110 @@ +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ActionMenu } from '@/components/action-menu'; +import { PrimaryButton, SecondaryButton } from '@/components/buttons'; +import { CaseDocument } from '@/components/case-document'; +import { openCitation } from '@/components/citation-target'; +import { DocumentScreen, DocumentTitle } from '@/components/document'; +import { DocumentHeader, type HeaderAction } from '@/components/document-header'; +import { DocumentStatus } from '@/components/document-status'; +import { backLabel } from '@/lib/back-label'; +import { formatWhen } from '@/lib/dates'; +import { copyText, sharePdf, shareText } from '@/lib/export'; +import { caseExportFileName, caseExportHtml } from '@/lib/export-html'; +import { caseToText, discussCaseMessage } from '@/lib/result-text'; +import { isCompleteCase } from '@/lib/results'; +import { useResultDocument } from '@/lib/use-result-document'; + +/** A generated case read as a document, as in mock 03. */ +export default function CaseScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const { id, from, notice: arrival, failed } = useLocalSearchParams<{ id: string; from?: string; notice?: string; failed?: string }>(); + const doc = useResultDocument('case', id); + const [exportOpen, setExportOpen] = useState(false); + const { setNotice } = doc; + + // Problems from saving a fresh result are told once, on arrival. + useEffect(() => { + if (arrival === 'recordFailed') setNotice(t('debate.recordFailed')); + if (arrival === 'sourcesFailed') setNotice(t('mobile.results.sourcesFailed', { count: Number(failed) || 1 })); + }, [arrival, failed, setNotice, t]); + + const result = doc.state.status === 'ready' ? doc.state.result : null; + const output = result && isCompleteCase(result.output) ? result.output : null; + const back = backLabel(t, from, 'prepare'); + + if (!result || !output) { + return ( + }> + + + ); + } + + const key = doc.state.status === 'ready' ? doc.state.key : id; + const title = result.title || output.metadata?.motion || t('export.debateCase'); + const side = output.metadata?.side === 'pro' ? t('generateResults.proposition') : t('generateResults.opposition'); + const generatedAt = Date.parse(output.metadata?.generatedAt ?? '') || result.createdAt; + + const run = (work: () => Promise) => { + work().catch((cause: unknown) => { + console.warn('Export failed:', cause instanceof Error ? cause.message : cause); + setNotice(t('mobile.results.exportFailed')); + }); + }; + + const actions: HeaderAction[] = [ + { key: 'discuss', label: t('common.discuss'), symbol: 'bubble.left.and.bubble.right', icon: 'chat', onPress: () => router.push({ pathname: '/ask', params: { prompt: discussCaseMessage(output) } }) }, + ...(result.savedId ? [] : [{ key: 'save', label: t('common.save'), symbol: 'square.and.arrow.down' as const, icon: 'download' as const, onPress: () => void doc.save() }]), + ...(doc.autoSaved.length > 0 + ? [{ key: 'undo-sources', label: t('autoSavedSources.undo'), symbol: 'arrow.uturn.backward' as const, icon: 'history' as const, onPress: () => void doc.undoAutoSaved() }] + : []), + ...(result.savedId ? [{ key: 'delete', label: t('common.delete'), symbol: 'trash' as const, icon: 'trash' as const, destructive: true, onPress: () => void doc.remove() }] : []), + ]; + + return ( + run(() => shareText(title, caseToText(output)))} actions={actions} />} + actions={ + <> + router.push({ pathname: '/attack', params: { caseKey: key } })} + testID="attack-this-case" + /> + setExportOpen(true)} disabled={doc.busy} testID="export-button" /> + + }> + + openCitation(router, target)} /> + setExportOpen(false)} + onSelect={(choice) => { + if (choice === 'pdf') run(() => sharePdf(caseExportHtml(output, t), caseExportFileName(output), title)); + if (choice === 'copy') run(async () => { + await copyText(caseToText(output)); + setNotice(t('generateResults.copiedToClipboard')); + }); + }} + /> + + ); +} diff --git a/apps/mobile/src/app/generate.tsx b/apps/mobile/src/app/generate.tsx new file mode 100644 index 000000000..8f8fd538d --- /dev/null +++ b/apps/mobile/src/app/generate.tsx @@ -0,0 +1,290 @@ +import type { GenerateInput } from '@debatelab/core/ai/schemas/generate'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { MainActionButton } from '@/components/buttons'; +import { DocumentScreen } from '@/components/document'; +import { DocumentHeader } from '@/components/document-header'; +import { Field } from '@/components/field'; +import { FormGroup, SelectRow } from '@/components/form-rows'; +import { SegmentedControl } from '@/components/segmented-control'; +import { Text } from '@/components/text'; +import { ToolError, ToolProgress, ToolTitle, WorkingIn } from '@/components/tool-form'; +import { AIError, isAbortError, type AIErrorKind } from '@/lib/ai-request'; +import { useAuth } from '@/lib/auth'; +import { backLabel } from '@/lib/back-label'; +import { buildDebateContext } from '@/lib/context-request'; +import { useDebates } from '@/lib/debate-context'; +import { fetchFeatureConfig } from '@/lib/feature-config'; +import { decideMotionTarget, shouldPrefillMotion } from '@/lib/motion'; +import { chooseMotionTarget } from '@/lib/motion-gate'; +import { keepOpenResult } from '@/lib/open-results'; +import { autosaveSources, generateCase, recordResult } from '@/lib/results'; +import { useSettings } from '@/lib/settings'; +import { useTheme } from '@/theme/use-theme'; + +// The website's defaults (GenerateForm). The website keeps the last form for the session; so does the app. +const DEFAULT_FORM: GenerateInput = { + motion: '', + side: 'pro', + format: 'custom', + role: 'r1', + length: 'standard', + audience: 'intermediate', + tone: 'formal', +}; +let lastForm: GenerateInput = DEFAULT_FORM; + +const PROGRESS = ['generateForm.loadingMsg1', 'generateForm.loadingMsg2', 'generateForm.loadingMsg3', 'generateForm.loadingMsg4', 'generateForm.loadingMsg5']; + +/** Generate, as the website's GeneratePage: a native form, a calm wait, then the case as a document. */ +export default function GenerateScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const { from } = useLocalSearchParams<{ from?: string }>(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const { debates, current, oneOff, create, switchTo, refresh } = useDebates(); + const { outputLanguage } = useSettings(); + const { colors } = useTheme(); + const [form, setFormState] = useState(lastForm); + const [motionError, setMotionError] = useState(false); + const [phase, setPhase] = useState<'form' | 'generating'>('form'); + const [error, setError] = useState(null); + const requests = useRef(0); + const abort = useRef(null); + const live = useRef(true); + + const setForm = useCallback((patch: Partial) => + setFormState((previous) => { + const next = { ...previous, ...patch }; + lastForm = next; + return next; + }), []); + + const abortActive = useCallback(() => { + requests.current += 1; + abort.current?.abort(); + abort.current = null; + }, []); + + // Leaving cancels a case still on its way, and nothing is saved. + useEffect(() => () => { + live.current = false; + abortActive(); + }, [abortActive]); + + // The motion follows the Current Debate unless the student typed their own. + useEffect(() => { + if (oneOff || !current) return; + if (shouldPrefillMotion(lastForm.motion, current.motion, debates.map((debate) => debate.motion))) setForm({ motion: current.motion }); + }, [current, debates, oneOff, setForm]); + + const start = async (input: GenerateInput, debateId: string | null, forUser: string) => { + abortActive(); + const request = requests.current; + const isCurrent = () => requests.current === request && live.current; + const controller = new AbortController(); + abort.current = controller; + setError(null); + setPhase('generating'); + try { + const [config, debateContext] = await Promise.all([ + fetchFeatureConfig(forUser, 'generate'), + debateId ? buildDebateContext(forUser, debateId, 'generate', input.side) : Promise.resolve(undefined), + ]); + if (!isCurrent()) return; + const { result, discoveredSources } = await generateCase(input, { config, language: outputLanguage, debateContext, signal: controller.signal }); + if (!isCurrent()) return; + const sources = await autosaveSources(forUser, discoveredSources, 'generate', debateId); + if (!isCurrent()) return; + let savedId: string | null = null; + let recordFailed = false; + if (debateId) { + try { + savedId = await recordResult(forUser, 'case', { title: input.motion, input, output: result, debateId, side: input.side }); + } catch (cause) { + console.warn('Could not save the case to the debate:', cause instanceof Error ? cause.message : cause); + recordFailed = true; + } + } + if (!isCurrent()) return; + const key = keepOpenResult({ + kind: 'case', + savedId, + title: input.motion, + side: input.side, + debateId, + createdAt: Date.now(), + input, + output: result, + sources: discoveredSources, + }); + const notice = recordFailed ? 'recordFailed' : sources.failed > 0 ? 'sourcesFailed' : undefined; + router.replace({ + pathname: '/case/[id]', + params: { id: key, from: from ?? 'prepare', ...(notice ? { notice } : {}), ...(sources.failed > 0 ? { failed: String(sources.failed) } : {}) }, + }); + } catch (cause) { + if (!isCurrent() || isAbortError(cause)) return; + const kind = cause instanceof AIError ? cause.kind : 'failed'; + if (!(cause instanceof AIError)) console.warn('Generate failed:', cause instanceof Error ? cause.message : cause); + // A missing debate was deleted elsewhere; reload the list so the app stops using it. + if (kind === 'debateMissing') void refresh(); + setError(kind); + setPhase('form'); + } finally { + if (requests.current === request) abort.current = null; + } + }; + + const submit = async () => { + if (!userId) return; + const input = { ...form, motion: form.motion.trim() }; + if (!input.motion) { + setMotionError(true); + return; + } + const forUser = userId; + // Where the case belongs is settled before anything is sent, as the website's motion gate does. + const decision = decideMotionTarget(input.motion, { debates, current, oneOff }); + if (decision.kind !== 'ask') { + void start(input, decision.kind === 'attach' ? decision.debateId : null, forUser); + return; + } + const choice = await chooseMotionTarget(decision.matches, { + title: current ? t('debate.mismatchTitle') : t('debate.offerTitle'), + message: `“${input.motion}”\n\n${current ? t('debate.mismatchDesc', { motion: current.motion }) : t('debate.offerDesc')}`, + switchLabel: t('debate.switchToExisting'), + createLabel: current ? t('debate.createFromMotion') : t('debate.startDebate'), + onceLabel: current ? t('debate.useOnce') : t('debate.offerNotNow'), + cancel: t('common.cancel'), + }); + if (!choice || !live.current) return; + let debateId: string | null = null; + try { + if (choice.kind === 'switch') { + await switchTo(choice.debateId); + debateId = choice.debateId; + } else if (choice.kind === 'create') { + debateId = (await create({ motion: input.motion, settings: {} })).id; + } + } catch (cause) { + console.warn('Could not save the debate:', cause instanceof Error ? cause.message : cause); + if (live.current) setError('debate'); + return; + } + if (live.current) void start(input, debateId, forUser); + }; + + const cancel = () => { + abortActive(); + setPhase('form'); + }; + + const generating = phase === 'generating'; + + return ( + } + actions={generating ? undefined : void submit()} />}> + + + {generating ? ( + t(key))} detail={form.motion} onCancel={cancel} /> + ) : ( + <> + { + setForm({ motion }); + if (motion.trim()) setMotionError(false); + }} + placeholder={t('generateForm.motionPlaceholder')} + multiline + invalid={motionError} + style={styles.motion} + testID="generate-motion" + /> + {motionError ? ( + + {t('debate.motionRequired')} + + ) : null} + + + {t('generateForm.side')} + + + options={[ + { value: 'pro', label: t('generateForm.sidePro') }, + { value: 'con', label: t('generateForm.sideCon') }, + ]} + value={form.side} + onChange={(side) => setForm({ side })} + /> + + + + first + label={t('generateForm.role')} + value={form.role} + onChange={(role) => setForm({ role })} + options={[ + { value: 'intro', label: t('generateForm.roleIntro') }, + { value: 'r1', label: t('generateForm.roleR1') }, + { value: 'r2', label: t('generateForm.roleR2') }, + { value: 'conc', label: t('generateForm.roleConc') }, + ]} + /> + + + + first + label={t('generateForm.length')} + value={form.length} + onChange={(length) => setForm({ length })} + options={[ + { value: 'brief', label: t('generateForm.lengthBrief') }, + { value: 'standard', label: t('generateForm.lengthStandard') }, + { value: 'comprehensive', label: t('generateForm.lengthComprehensive') }, + ]} + /> + + label={t('generateForm.audience')} + value={form.audience} + onChange={(audience) => setForm({ audience })} + options={[ + { value: 'beginner', label: t('generateForm.audienceBeginner') }, + { value: 'intermediate', label: t('generateForm.audienceIntermediate') }, + { value: 'advanced', label: t('generateForm.audienceAdvanced') }, + ]} + /> + + label={t('generateForm.tone')} + value={form.tone} + onChange={(tone) => setForm({ tone })} + options={[ + { value: 'formal', label: t('generateForm.toneFormal') }, + { value: 'conversational', label: t('generateForm.toneConversational') }, + { value: 'aggressive', label: t('generateForm.toneAggressive') }, + ]} + /> + + {error ? void submit()} /> : null} + + )} + + ); +} + +const styles = StyleSheet.create({ + motion: { minHeight: 92, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top' }, + invalid: { fontSize: 14, marginTop: -6, marginLeft: 4 }, + side: { gap: 7 }, + label: { fontSize: 14, marginLeft: 4 }, +}); diff --git a/apps/mobile/src/components/action-menu.tsx b/apps/mobile/src/components/action-menu.tsx new file mode 100644 index 000000000..429fb096d --- /dev/null +++ b/apps/mobile/src/components/action-menu.tsx @@ -0,0 +1,63 @@ +import { useEffect, useRef } from 'react'; +import { ActionSheetIOS, Alert, Platform } from 'react-native'; + +import type { IconName } from './icon-paths'; + +export interface MenuItem { + key: string; + label: string; + icon?: IconName; + destructive?: boolean; +} + +export interface ActionMenuProps { + visible: boolean; + title?: string; + items: MenuItem[]; + cancelLabel: string; + onSelect: (key: string) => void; + onClose: () => void; + /** Where the web preview draws it; iOS always uses the action sheet. */ + placement?: 'top' | 'bottom'; + testID?: string; +} + +/** A native iOS action sheet, opened while `visible` is true. The web preview draws its own (action-menu.web.tsx). */ +export function ActionMenu({ visible, title, items, cancelLabel, onSelect, onClose }: ActionMenuProps) { + const latest = useRef({ items, onSelect, onClose }); + latest.current = { items, onSelect, onClose }; + useEffect(() => { + if (!visible) return; + let live = true; + const choose = (index: number | undefined) => { + if (!live) return; + const { items: current, onSelect: select, onClose: close } = latest.current; + close(); + const item = index === undefined ? undefined : current[index]; + if (item) select(item.key); + }; + const labels = latest.current.items.map((item) => item.label); + if (Platform.OS === 'ios') { + ActionSheetIOS.showActionSheetWithOptions( + { + title, + options: [...labels, cancelLabel], + cancelButtonIndex: labels.length, + destructiveButtonIndex: latest.current.items.flatMap((item, index) => (item.destructive ? [index] : [])), + }, + (index) => choose(index < labels.length ? index : undefined), + ); + } else { + Alert.alert(title ?? '', undefined, [ + ...labels.map((label, index) => ({ text: label, onPress: () => choose(index) })), + { text: cancelLabel, style: 'cancel' as const, onPress: () => choose(undefined) }, + ], { cancelable: true, onDismiss: () => choose(undefined) }); + } + return () => { + live = false; + }; + // Shown once per opening; the latest items and handlers are read from the ref. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible]); + return null; +} diff --git a/apps/mobile/src/components/action-menu.web.tsx b/apps/mobile/src/components/action-menu.web.tsx new file mode 100644 index 000000000..651777f94 --- /dev/null +++ b/apps/mobile/src/components/action-menu.web.tsx @@ -0,0 +1,51 @@ +import { Modal, Pressable, StyleSheet, View } from 'react-native'; + +import type { ActionMenuProps } from './action-menu'; +import { Icon } from './icon'; +import { Text } from './text'; + +import { useTheme } from '@/theme/use-theme'; + +/** react-native-web has no action sheet, so the web preview draws a menu like the Library's. */ +export function ActionMenu({ visible, title, items, cancelLabel, onSelect, onClose, placement = 'bottom', testID }: ActionMenuProps) { + const { colors } = useTheme(); + if (!visible) return null; + return ( + + + + {title ? ( + + {title} + + ) : null} + {items.map((item, index) => ( + { + onClose(); + onSelect(item.key); + }} + style={({ pressed }) => [styles.item, (index > 0 || title) && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && { backgroundColor: colors.line }]}> + {item.label} + {item.icon ? : null} + + ))} + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { flex: 1, backgroundColor: 'rgba(15,23,42,0.18)', paddingHorizontal: 14 }, + top: { alignItems: 'flex-end', paddingTop: 58 }, + bottom: { justifyContent: 'flex-end', paddingBottom: 28 }, + menu: { borderRadius: 16, borderWidth: StyleSheet.hairlineWidth, overflow: 'hidden', boxShadow: '0 14px 40px -10px rgba(15,23,42,0.4)' }, + menuTop: { width: 268 }, + menuBottom: { width: '100%', maxWidth: 520, alignSelf: 'center' }, + title: { fontSize: 13.5, textAlign: 'center', paddingVertical: 12, paddingHorizontal: 16 }, + item: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 17, minHeight: 52, gap: 12 }, + label: { fontSize: 17, flexShrink: 1 }, +}); diff --git a/apps/mobile/src/components/ask-answer.tsx b/apps/mobile/src/components/ask-answer.tsx index 9e2205200..8015100e5 100644 --- a/apps/mobile/src/components/ask-answer.tsx +++ b/apps/mobile/src/components/ask-answer.tsx @@ -6,35 +6,12 @@ import { useTranslation } from 'react-i18next'; import { ScrollView, StyleSheet, View } from 'react-native'; import { Chip, CiteBadge } from './chip'; +import { firstUrl, hostOf, openCitation, shorten, type CitationTarget } from './citation-target'; import { MarkdownView } from './markdown-view'; import { Text } from './text'; import type { Source } from '@/lib/sources'; -interface CitationTarget { - n: number; - label: string; - url?: string; - sourceId?: string; -} - -/** The first web address in a citation's source, as the website's getFirstUrl. */ -function firstUrl(text?: string): string | undefined { - return text?.match(/https?:\/\/[^\s]+/i)?.[0]?.replace(/[),.;!?]+$/g, ''); -} - -function hostOf(url: string): string { - try { - return new URL(url).hostname.replace(/^www\./, ''); - } catch { - return url; - } -} - -function shorten(text: string, max = 42): string { - return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text; -} - /** Each citation as a chip: a Library source opens its detail, a web address opens the in-app browser. */ function citationTargets(output: AskOutput | undefined, library: Source[]): CitationTarget[] { return (output?.citations ?? []).map((citation, index) => { @@ -62,10 +39,7 @@ export function AskAnswer({ content, output, streaming, library }: { content: st const cited = new Set(targets.map((target) => target.url).filter(Boolean)); const webSources = (output?.searchInfo?.webSearchUsed ? output.searchInfo.sources : []).filter((item) => !cited.has(item.url)); - const open = (target: CitationTarget | undefined) => { - if (target?.sourceId) router.push({ pathname: '/source/[id]', params: { id: target.sourceId } }); - else if (target?.url) void WebBrowser.openBrowserAsync(target.url); - }; + const open = (target: CitationTarget | undefined) => openCitation(router, target); return ( diff --git a/apps/mobile/src/components/attack-document.tsx b/apps/mobile/src/components/attack-document.tsx new file mode 100644 index 000000000..83b234f40 --- /dev/null +++ b/apps/mobile/src/components/attack-document.tsx @@ -0,0 +1,165 @@ +import type { DiscoveredSource } from '@debatelab/core/ai/discovered-sources'; +import type { AttackOutput, Weakness } from '@debatelab/core/ai/schemas/attack'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { ResearchedSources } from './case-document'; +import { Bullets, Callout, Divider, DocSection, Labeled, NumberedItems, Paragraph, Quote, SectionHeading, SubHeading, Tag } from './document'; +import { Text } from './text'; + +import { useTheme } from '@/theme/use-theme'; + +const SEVERITIES = ['critical', 'major', 'minor'] as const; + +/** Every part of the website's AttackResults, in the same document style as a case. */ +export function AttackDocument({ output, sources }: { output: AttackOutput; sources: DiscoveredSource[] }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const severityColor = { critical: colors.destructive, major: colors.warning, minor: colors.primary }; + const weaknesses = Array.isArray(output.weaknesses) ? output.weaknesses : []; + const fallacies = Array.isArray(output.fallaciesDetected) ? output.fallaciesDetected : []; + const crossEx = Array.isArray(output.crossExQuestions) ? output.crossExQuestions : []; + const overall = output.overallAssessment ?? { summary: '', score: 0, strengthAreas: [] }; + const strengths = Array.isArray(overall.strengthAreas) ? overall.strengthAreas : []; + const score = Math.max(0, Math.min(100, overall.score)); + const scoreColor = score >= 70 ? colors.primary : score >= 50 ? colors.warning : colors.destructive; + const severityOf = (weakness: Weakness) => (SEVERITIES.includes(weakness.severity) ? weakness.severity : 'minor'); + + const items = weaknesses.map((weakness, index) => { + const severity = severityOf(weakness); + const severityLabel = t(`attackResults.${severity}`); + const typeLabel = t(`attackResults.types.${weakness.type}`, { defaultValue: weakness.type }); + return { + key: weakness.id || `${index}`, + title: weakness.description, + summary: `${severityLabel} · ${typeLabel}`, + body: ( + <> + + + + + {weakness.affectedQuote ? : null} + {t('attackResults.whyWeak')} + + + + + + + ), + }; + }); + + return ( + <> + + + + {t('attackResults.caseStrength')} + + + {`${overall.score}/100`} + + + + + + {overall.summary ? ( + + {overall.summary} + + ) : null} + + {SEVERITIES.map((severity) => ( + + + + {`${weaknesses.filter((weakness) => severityOf(weakness) === severity).length} ${t(`attackResults.${severity}`)}`} + + + ))} + + + + {strengths.length > 0 ? ( + + + + ) : null} + + + + + {fallacies.length > 0 ? ( + + {fallacies.map((fallacy, index) => ( + + {index > 0 ? : null} + + + + {`${t('attackResults.in')} ${fallacy.location}`} + + + + + ))} + + ) : null} + + + {crossEx.map((question, index) => ( + + {index > 0 ? : null} + + {`"${question.question}"`} + + + + + ))} + + + {output.weighingAnalysis ? ( + + {t('attackResults.currentWeighing')} + + {output.weighingAnalysis.currentWeighing} + + + {t('attackResults.missingComparisons')} + + + {t('attackResults.improvements')} + + + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + score: { borderWidth: 1, borderRadius: 17, paddingHorizontal: 16, paddingVertical: 14, gap: 10 }, + scoreRow: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between' }, + scoreLabel: { fontSize: 15 }, + scoreValue: { fontSize: 22 }, + track: { height: 10, borderRadius: 5, overflow: 'hidden' }, + fill: { height: '100%', borderRadius: 5 }, + summary: { fontSize: 16, lineHeight: 23 }, + counts: { flexDirection: 'row', flexWrap: 'wrap', gap: 14 }, + count: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + dot: { width: 10, height: 10, borderRadius: 5 }, + countText: { fontSize: 14.5 }, + tags: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 6 }, + entry: { gap: 8 }, + location: { fontSize: 13.5 }, + question: { fontSize: 15.5, lineHeight: 22 }, + body: { fontSize: 15.5, lineHeight: 22 }, +}); diff --git a/apps/mobile/src/components/buttons.tsx b/apps/mobile/src/components/buttons.tsx index 6dd81b607..65e2e9025 100644 --- a/apps/mobile/src/components/buttons.tsx +++ b/apps/mobile/src/components/buttons.tsx @@ -45,6 +45,8 @@ export function PrimaryButton({ busy, disabled, destructive, + floating, + testID, }: { label: string; icon?: IconName; @@ -52,8 +54,11 @@ export function PrimaryButton({ busy?: boolean; disabled?: boolean; destructive?: boolean; + /** Pinned above the content, as the mocks' bottom actions are. */ + floating?: boolean; + testID?: string; }) { - const { colors } = useTheme(); + const { colors, scheme } = useTheme(); const inactive = disabled || busy; const foreground = destructive ? '#ffffff' : colors.onPrimary; return ( @@ -62,9 +67,11 @@ export function PrimaryButton({ accessibilityState={{ disabled: inactive, busy }} disabled={inactive} onPress={onPress} + testID={testID} style={({ pressed }) => [ styles.primary, { backgroundColor: destructive ? colors.destructive : colors.primary }, + floating && scheme === 'light' && styles.primaryShadow, (pressed || inactive) && styles.dimmed, ]}> {busy ? : icon ? : null} @@ -75,6 +82,45 @@ export function PrimaryButton({ ); } +/** The outlined button beside a primary one, as "Attack this case" in mock 03. */ +export function SecondaryButton({ + label, + icon, + onPress, + disabled, + floating, + testID, +}: { + label: string; + icon?: IconName; + onPress: () => void; + disabled?: boolean; + floating?: boolean; + testID?: string; +}) { + const { colors, scheme } = useTheme(); + return ( + [ + styles.primary, + styles.secondary, + { backgroundColor: colors.input, borderColor: colors.border }, + floating && scheme === 'light' && styles.secondaryShadow, + (pressed || disabled) && styles.dimmed, + ]}> + {icon ? : null} + + {label} + + + ); +} + /** A secondary action shown as tinted text, like an iOS plain button. */ export function TextButton({ label, @@ -124,7 +170,10 @@ const styles = StyleSheet.create({ gap: 8, paddingHorizontal: 20, }, - primaryLabel: { fontSize: 17 }, + primaryLabel: { fontSize: 17, flexShrink: 1 }, + primaryShadow: { boxShadow: '0 6px 18px -8px rgba(2,132,199,0.6)' }, + secondary: { borderWidth: 1, paddingHorizontal: 14 }, + secondaryShadow: { boxShadow: '0 6px 18px -8px rgba(15,23,42,0.35)' }, dimmed: { opacity: 0.6 }, text: { minHeight: 44, alignItems: 'center', justifyContent: 'center' }, textLabel: { fontSize: 16 }, diff --git a/apps/mobile/src/components/case-document.tsx b/apps/mobile/src/components/case-document.tsx new file mode 100644 index 000000000..01a80fbda --- /dev/null +++ b/apps/mobile/src/components/case-document.tsx @@ -0,0 +1,241 @@ +import type { DiscoveredSource } from '@debatelab/core/ai/discovered-sources'; +import type { Contention, GenerateOutput } from '@debatelab/core/ai/schemas/generate'; +import * as WebBrowser from 'expo-web-browser'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { hostOf, shorten, type CitationTarget } from './citation-target'; +import { + Bullets, + Callout, + Divider, + DocSection, + FramingCard, + Labeled, + NumberedItems, + Paragraph, + Quote, + SubHeading, + Tag, +} from './document'; +import { Icon } from './icon'; +import { Text } from './text'; + +import type { Source } from '@/lib/sources'; +import { useTheme } from '@/theme/use-theme'; + +type SourceUsage = NonNullable[number]; + +const MIN_TITLE_MATCH = 4; + +/** + * The sources an argument names, as citation chips. The server records which + * sources the case used (metadata.sourceUsage); an argument gets a chip for + * each one whose title or site it mentions, then for Library sources it names, + * as Ask's answers match them. Numbers stay the same across arguments. + */ +function argumentChips(contention: Contention, usage: SourceUsage[], library: Source[]): CitationTarget[] { + const text = `${contention.claim} ${contention.reasoning} ${contention.evidence} ${contention.impact}`.toLocaleLowerCase(); + const mentions = (title: string) => { + const needle = title.trim().toLocaleLowerCase(); + return needle.length >= MIN_TITLE_MATCH && text.includes(needle); + }; + const chips: CitationTarget[] = []; + const libraryIds = new Set(library.map((source) => source.id)); + usage.forEach((item, index) => { + if (item.judgment === 'not_used') return; + const host = item.origin && /^https?:\/\//i.test(item.origin) ? hostOf(item.origin) : ''; + if (!mentions(item.title) && !(host && text.includes(host.toLocaleLowerCase()))) return; + chips.push({ + n: index + 1, + label: shorten(item.title || host), + sourceId: item.id && libraryIds.has(item.id) ? item.id : undefined, + url: item.origin && /^https?:\/\//i.test(item.origin) ? item.origin : undefined, + }); + }); + const named = new Set(usage.map((item) => item.id).filter(Boolean)); + library.forEach((source, index) => { + if (named.has(source.id) || !mentions(source.title)) return; + chips.push({ n: usage.length + index + 1, label: shorten(source.title), sourceId: source.id }); + }); + return chips; +} + +/** Every part of the website's GenerateResults, in mock 03's document style. */ +export function CaseDocument({ + output, + library, + sources, + onCite, +}: { + output: GenerateOutput; + library: Source[]; + /** Researched sources, known only for a case generated in this session. */ + sources: DiscoveredSource[]; + onCite: (target: CitationTarget) => void; +}) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const chipsByArgument = useMemo( + () => output.contentions.map((contention) => argumentChips(contention, output.metadata?.sourceUsage ?? [], library)), + [output, library], + ); + + const items = output.contentions.map((contention, index) => { + const chips = chipsByArgument[index]; + const pieces = chips.length || (contention.evidence.trim() ? 1 : 0); + return { + key: `${index}`, + title: contention.claim, + summary: pieces > 0 + ? t('mobile.results.evidenceCount', { count: pieces }) + : contention.needsSource ? t('generateResults.needsSource') : t('mobile.results.noEvidence'), + body: ( + <> + {contention.needsSource ? : null} + + {contention.evidence.trim() ? : null} + {contention.sourceQuery ? : null} + {contention.impact ? : null} + + ), + }; + }); + + const durationMs = output.metadata?.durationMs; + const duration = durationMs && durationMs > 0 + ? durationMs < 1000 + ? t('generateResults.durationMs', { value: durationMs }) + : t('generateResults.durationS', { value: (durationMs / 1000).toFixed(durationMs < 10000 ? 1 : 0) }) + : null; + const model = output.metadata?.model || output.metadata?.provider; + const footer = [model ? t('generateResults.modelLabel', { value: model }) : null, duration].filter(Boolean).join(' • '); + const definitions = output.caseStructure.keyDefinitions ?? []; + + return ( + <> + block.text?.trim())} + /> + + {output.exordio && (output.exordio.hook || output.exordio.transition) ? ( + + {t('generateResults.hook')} + + {output.exordio.hook} + + + {t('generateResults.transition')} + + {output.exordio.transition} + + + ) : null} + + + + + {t('generateResults.burdenOfProof')} + + + {t('generateResults.keyDefinitions')} + {definitions.length > 0 ? : {t('generateResults.noDefinitions')}} + + + + {t('generateResults.likelyArguments')} + + + {t('generateResults.strategicWeaknesses')} + + + {t('generateResults.keyClashes')} + + {(output.predictedOpponentCase.keyClashes ?? []).map((clash, index) => ( + + ))} + + + + + {output.counters.map((counter, index) => ( + + {index > 0 ? : null} + + + + {counter.targetArgument} + + + + + + {counter.rebuttal} + + + + ))} + + + + + {footer ? ( + + {footer} + + ) : null} + + ); +} + +/** The website's researched-sources panel: each source with its quality, opening in the in-app browser. */ +export function ResearchedSources({ sources }: { sources: DiscoveredSource[] }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + if (sources.length === 0) return null; + return ( + + {sources.map((source, index) => ( + void WebBrowser.openBrowserAsync(source.url)} + style={({ pressed }) => [styles.source, index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> + + + + {source.title} + + + {[ + hostOf(source.url), + source.qualityLabel ? t(`sourceQuality.labels.${source.qualityLabel}`) : null, + source.qualityLabel === 'weak' ? t('sourceQuality.fallbackOnly') : null, + source.semantic?.lastUsedInCase ? t('sourceQuality.usedInCase') : null, + ].filter(Boolean).join(' · ')} + + + + ))} + + ); +} + +const styles = StyleSheet.create({ + body: { fontSize: 15.5, lineHeight: 22 }, + italic: { fontSize: 15.5, lineHeight: 22, fontStyle: 'italic' }, + tags: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 }, + counter: { gap: 8 }, + counterTarget: { flexDirection: 'row', alignItems: 'flex-start', gap: 8 }, + counterTargetText: { flex: 1, fontSize: 15.5, lineHeight: 21 }, + footer: { fontSize: 13, textAlign: 'center', marginTop: 6 }, + source: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 8 }, + sourceText: { flex: 1, gap: 2 }, + sourceTitle: { fontSize: 15, lineHeight: 20 }, + sourceMeta: { fontSize: 13 }, + pressed: { opacity: 0.7 }, +}); diff --git a/apps/mobile/src/components/citation-target.ts b/apps/mobile/src/components/citation-target.ts new file mode 100644 index 000000000..fe6dc5b20 --- /dev/null +++ b/apps/mobile/src/components/citation-target.ts @@ -0,0 +1,32 @@ +import type { useRouter } from 'expo-router'; +import * as WebBrowser from 'expo-web-browser'; + +/** Where a citation chip leads: a Library source opens its detail, a web address opens the in-app browser. */ +export interface CitationTarget { + n: number; + label: string; + url?: string; + sourceId?: string; +} + +/** The first web address in a citation's source, as the website's getFirstUrl. */ +export function firstUrl(text?: string): string | undefined { + return text?.match(/https?:\/\/[^\s]+/i)?.[0]?.replace(/[),.;!?]+$/g, ''); +} + +export function hostOf(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, ''); + } catch { + return url; + } +} + +export function shorten(text: string, max = 42): string { + return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text; +} + +export function openCitation(router: ReturnType, target: CitationTarget | undefined) { + if (target?.sourceId) router.push({ pathname: '/source/[id]', params: { id: target.sourceId } }); + else if (target?.url) void WebBrowser.openBrowserAsync(target.url); +} diff --git a/apps/mobile/src/components/document-header.tsx b/apps/mobile/src/components/document-header.tsx new file mode 100644 index 000000000..e300e3da1 --- /dev/null +++ b/apps/mobile/src/components/document-header.tsx @@ -0,0 +1,57 @@ +import { Stack } from 'expo-router'; +import type { ComponentProps } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { IconName } from './icon-paths'; + +import { fontFamily } from '@/theme/fonts'; +import { useTheme } from '@/theme/use-theme'; + +type SFSymbol = Extract['icon'], string>; + +export interface HeaderAction { + key: string; + label: string; + /** The SF Symbol on iOS, and the icon the web preview draws. */ + symbol: SFSymbol; + icon: IconName; + destructive?: boolean; + onPress: () => void; +} + +/** + * The document's navigation bar from mock 03: back to where it was opened, + * a share button and a "..." menu, in the native iOS header. + */ +export function DocumentHeader({ backLabel, onShare, actions }: { backLabel: string; onShare?: () => void; actions: HeaderAction[] }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + return ( + <> + + + {onShare ? : null} + {actions.length > 0 ? ( + + {actions.map((action) => ( + + {action.label} + + ))} + + ) : null} + + + ); +} diff --git a/apps/mobile/src/components/document-header.web.tsx b/apps/mobile/src/components/document-header.web.tsx new file mode 100644 index 000000000..194602818 --- /dev/null +++ b/apps/mobile/src/components/document-header.web.tsx @@ -0,0 +1,59 @@ +import { useRouter } from 'expo-router'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { ActionMenu } from './action-menu'; +import type { HeaderAction } from './document-header'; +import { Icon } from './icon'; +import { Text } from './text'; + +import { useTheme } from '@/theme/use-theme'; + +/** The web preview has no native header, so it draws mock 03's bar and its menu. */ +export function DocumentHeader({ backLabel, onShare, actions }: { backLabel: string; onShare?: () => void; actions: HeaderAction[] }) { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + const [open, setOpen] = useState(false); + return ( + + router.back()} style={styles.back} testID="document-back"> + + + {backLabel} + + + + {onShare ? ( + + + + ) : null} + {actions.length > 0 ? ( + setOpen(true)} hitSlop={8} style={styles.icon} testID="document-more"> + + + ) : null} + + ({ key, label, icon, destructive }))} + cancelLabel={t('common.cancel')} + onClose={() => setOpen(false)} + onSelect={(key) => actions.find((action) => action.key === key)?.onPress()} + /> + + ); +} + +const styles = StyleSheet.create({ + bar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 10, paddingBottom: 6 }, + back: { flexDirection: 'row', alignItems: 'center', minHeight: 44, flexShrink: 1 }, + backLabel: { fontSize: 18 }, + actions: { flexDirection: 'row', alignItems: 'center', gap: 14, paddingRight: 8 }, + icon: { minWidth: 36, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, +}); diff --git a/apps/mobile/src/components/document-status.tsx b/apps/mobile/src/components/document-status.tsx new file mode 100644 index 000000000..52cebe2d7 --- /dev/null +++ b/apps/mobile/src/components/document-status.tsx @@ -0,0 +1,35 @@ +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, StyleSheet, View } from 'react-native'; + +import { Text } from './text'; + +/** What a document screen shows before its result is ready, or when it cannot be shown. */ +export function DocumentStatus({ status, incomplete }: { status: 'loading' | 'missing' | 'failed' | 'incomplete'; incomplete?: { title: string; body: string } }) { + const { t } = useTranslation(); + if (status === 'loading') return ; + return ( + + {status === 'incomplete' && incomplete ? ( + <> + + {incomplete.title} + + + {incomplete.body} + + + ) : ( + + {status === 'missing' ? t('mobile.results.notFound') : t('mobile.results.loadFailed')} + + )} + + ); +} + +const styles = StyleSheet.create({ + loading: { marginTop: 48 }, + box: { gap: 6, marginTop: 24 }, + title: { fontSize: 18 }, + body: { fontSize: 16, lineHeight: 22 }, +}); diff --git a/apps/mobile/src/components/document.tsx b/apps/mobile/src/components/document.tsx new file mode 100644 index 000000000..59776dc73 --- /dev/null +++ b/apps/mobile/src/components/document.tsx @@ -0,0 +1,354 @@ +import { Children, Fragment, isValidElement, useState, type ReactNode } from 'react'; +import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { Chip, CiteBadge } from './chip'; +import type { CitationTarget } from './citation-target'; +import { Icon } from './icon'; +import { MarkdownView } from './markdown-view'; +import { Text } from './text'; + +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +/** + * A result read as a document, as in mock 03: a large title and meta line, a + * pale-blue framing card, numbered arguments that collapse, quotes with a + * left rule and citation chips, and the next steps pinned above the home bar. + * Generate and Attack use these; Brainstorm and Factoids will too. + */ + +const BODY = 15.5; +const PINNED_HEIGHT = 54; + +/** The scrolling page under the header, leaving room for the pinned actions. */ +export function DocumentScreen({ header, children, actions, notice, testID }: { header: ReactNode; children: ReactNode; actions?: ReactNode; notice?: string; testID?: string }) { + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const bottom = Math.max(insets.bottom - 6, 14); + return ( + + {header} + + {children} + + {notice ? ( + + {notice} + + ) : null} + {actions ? ( + + {/* The pinned buttons share the width equally, as in the mock. */} + {Children.map(unwrapFragment(actions), (action) => (action ? {action} : null))} + + ) : null} + + ); +} + +function unwrapFragment(node: ReactNode): ReactNode { + return isValidElement<{ children?: ReactNode }>(node) && node.type === Fragment ? node.props.children : node; +} + +export function DocumentTitle({ title, meta }: { title: string; meta?: string }) { + return ( + + + {title} + + {meta ? ( + + {meta} + + ) : null} + + ); +} + +/** The pale-blue card the document opens with. */ +export function FramingCard({ blocks }: { blocks: { label: string; text: string }[] }) { + const { colors } = useTheme(); + return ( + + {blocks.map((block, index) => ( + + + {block.label} + + + {block.text} + + + ))} + + ); +} + +export interface NumberedItem { + key: string; + title: string; + /** What a collapsed item says under its title, such as "2 pieces of evidence". */ + summary: string; + body: ReactNode; +} + +/** + * Numbered items that open one at a time or together. The first is open. An + * open item gets its own card; neighbouring closed items share one, as in the mock. + */ +export function NumberedItems({ items, testID }: { items: NumberedItem[]; testID?: string }) { + const { colors } = useTheme(); + const [open, setOpen] = useState>(() => new Set(items[0] ? [items[0].key] : [])); + const toggle = (key: string) => + setOpen((previous) => { + const next = new Set(previous); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + + // Runs of closed items share a card. + const groups: { open: boolean; entries: { item: NumberedItem; n: number }[] }[] = []; + items.forEach((item, index) => { + const isOpen = open.has(item.key); + const last = groups[groups.length - 1]; + if (!isOpen && last && !last.open) last.entries.push({ item, n: index + 1 }); + else groups.push({ open: isOpen, entries: [{ item, n: index + 1 }] }); + }); + + return ( + + {groups.map((group) => ( + + {group.entries.map(({ item, n }, index) => ( + + {index > 0 ? : null} + toggle(item.key)} + testID="document-item" + style={({ pressed }) => [styles.itemHeader, pressed && styles.pressed]}> + + {n} + + + + {item.title} + + {group.open ? null : ( + + {item.summary} + + )} + + + + {group.open ? {item.body} : null} + + ))} + + ))} + + ); +} + +/** Body text that may carry Markdown, as the website renders model output. */ +export function Paragraph({ text, tone = 'secondary' }: { text: string; tone?: 'foreground' | 'secondary' }) { + return ; +} + +/** A labelled line, like the website's "Impact:" rows. */ +export function Labeled({ label, text, italic }: { label: string; text: string; italic?: boolean }) { + return ( + + + {label.replace(/:?$/, ':')}{' '} + + + {text} + + + ); +} + +/** A quote with the mock's left rule, followed by its citation chips. */ +export function Quote({ text, chips = [], onCite, italic }: { text: string; chips?: CitationTarget[]; onCite?: (target: CitationTarget) => void; italic?: boolean }) { + const { colors } = useTheme(); + return ( + + + {text} + {chips.map((chip) => ( + onCite(chip) : undefined}> + {' '} + + + ))} + + + ); +} + +/** The sources behind citation chips, as a row of chips. */ +export function SourceChips({ chips, onCite }: { chips: CitationTarget[]; onCite: (target: CitationTarget) => void }) { + if (chips.length === 0) return null; + return ( + + {chips.map((chip) => ( + } onPress={() => onCite(chip)} /> + ))} + + ); +} + +export function SectionHeading({ title, description }: { title: string; description?: string }) { + return ( + + + {title} + + {description ? ( + + {description} + + ) : null} + + ); +} + +/** A titled part of the document, in the same card style as the arguments. */ +export function DocSection({ title, description, children, testID }: { title: string; description?: string; children: ReactNode; testID?: string }) { + const { colors } = useTheme(); + return ( + + + + {title} + + {description ? ( + + {description} + + ) : null} + + {children} + + ); +} + +/** A small heading inside a section. */ +export function SubHeading({ children }: { children: string }) { + return ( + + {children} + + ); +} + +export function Bullets({ items, marker = '•', color }: { items: string[]; marker?: string; color?: string }) { + const { colors } = useTheme(); + if (items.length === 0) return null; + return ( + + {items.map((item, index) => ( + + + {marker} + + + {item} + + + ))} + + ); +} + +/** A small outlined label, such as a severity or "Needs Source". */ +export function Tag({ label, color, icon }: { label: string; color?: string; icon?: 'alert' }) { + const { colors } = useTheme(); + const tint = color ?? colors.secondary; + return ( + + {icon ? : null} + + {label} + + + ); +} + +export function Divider() { + const { colors } = useTheme(); + return ; +} + +/** A tinted box inside an item, such as the website's "Improved Version". */ +export function Callout({ title, children }: { title: string; children: ReactNode }) { + const { colors } = useTheme(); + return ( + + + {title} + + {children} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1 }, + content: { paddingHorizontal: 18, paddingTop: 4, gap: 12, width: '100%', maxWidth: 680, alignSelf: 'center' }, + pinned: { position: 'absolute', left: 14, right: 14, flexDirection: 'row', gap: 10 }, + pinnedItem: { flex: 1 }, + pressed: { opacity: 0.7 }, + notice: { position: 'absolute', left: 24, right: 24, borderRadius: 14, paddingHorizontal: 16, paddingVertical: 11, alignSelf: 'center', maxWidth: 560 }, + noticeText: { fontSize: 15, lineHeight: 20, textAlign: 'center' }, + titleBlock: { gap: 6, marginBottom: 4 }, + title: { fontSize: 30, lineHeight: 36, letterSpacing: -0.8 }, + meta: { fontSize: 15, lineHeight: 20 }, + framing: { borderWidth: 1, borderRadius: radius.card + 1, paddingHorizontal: 16, paddingVertical: 14, gap: 12 }, + framingBlock: { gap: 4 }, + framingLabel: { fontSize: 15 }, + framingText: { fontSize: 15.5, lineHeight: 22.5 }, + items: { gap: 10 }, + itemCard: { borderWidth: 1, borderRadius: radius.card + 1, overflow: 'hidden' }, + itemHeader: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 13, minHeight: 64 }, + itemNumber: { fontSize: 17, minWidth: 14 }, + itemNumberOpen: { alignSelf: 'flex-start', marginTop: 1 }, + itemHeaderText: { flex: 1, gap: 2 }, + itemTitle: { fontSize: 16.5, lineHeight: 22 }, + itemTitleOpen: { fontSize: 17.5, lineHeight: 23 }, + itemSummary: { fontSize: 14.5, lineHeight: 19 }, + itemBody: { paddingLeft: 42, paddingRight: 16, paddingBottom: 16, gap: 10, marginTop: -4 }, + labeled: { fontSize: BODY, lineHeight: 22 }, + italic: { fontStyle: 'italic' }, + quote: { borderLeftWidth: 3.5, paddingLeft: 12, paddingVertical: 2 }, + quoteText: { fontSize: 15, lineHeight: 22 }, + chipRow: { flexGrow: 0, marginHorizontal: -16 }, + chips: { gap: 8, paddingHorizontal: 16 }, + section: { gap: 8, marginTop: 8 }, + sectionHead: { gap: 2, marginHorizontal: 2 }, + sectionHeading: { marginTop: 8 }, + sectionTitle: { fontSize: 19, lineHeight: 25, letterSpacing: -0.2 }, + sectionDescription: { fontSize: 14, lineHeight: 19 }, + sectionCard: { borderWidth: 1, borderRadius: radius.card + 1, padding: 16, gap: 12 }, + subHeading: { fontSize: 14, lineHeight: 19 }, + bullets: { gap: 6 }, + bullet: { flexDirection: 'row', gap: 8 }, + bulletMarker: { fontSize: BODY, lineHeight: 22, minWidth: 12 }, + bulletText: { flex: 1, fontSize: BODY, lineHeight: 22 }, + tag: { flexDirection: 'row', alignItems: 'center', gap: 4, alignSelf: 'flex-start', borderWidth: 1, borderRadius: 7, paddingHorizontal: 7, paddingVertical: 2 }, + tagText: { fontSize: 12.5, lineHeight: 17 }, + divider: { height: 1 }, + callout: { borderWidth: 1, borderRadius: 12, padding: 12, gap: 4 }, + calloutTitle: { fontSize: 14.5 }, +}); diff --git a/apps/mobile/src/components/form-rows.tsx b/apps/mobile/src/components/form-rows.tsx new file mode 100644 index 000000000..88be58e9a --- /dev/null +++ b/apps/mobile/src/components/form-rows.tsx @@ -0,0 +1,125 @@ +import { useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, Switch, View } from 'react-native'; + +import { ActionMenu } from './action-menu'; +import { Icon } from './icon'; +import { Text } from './text'; + +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +/** An inset group of rows, iOS Settings style. */ +export function FormGroup({ label, children, footer }: { label?: string; children: ReactNode; footer?: string }) { + const { colors } = useTheme(); + return ( + + {label ? ( + + {label} + + ) : null} + {children} + {footer ? ( + + {footer} + + ) : null} + + ); +} + +function RowFrame({ first, children }: { first?: boolean; children: ReactNode }) { + const { colors } = useTheme(); + return {children}; +} + +/** A row that shows the chosen option and opens the native picker sheet. */ +export function SelectRow({ + label, + value, + options, + onChange, + first, + disabled, + testID, +}: { + label: string; + value: T; + options: { value: T; label: string }[]; + onChange: (value: T) => void; + first?: boolean; + disabled?: boolean; + testID?: string; +}) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const [open, setOpen] = useState(false); + const chosen = options.find((option) => option.value === value)?.label ?? value; + return ( + <> + setOpen(true)} + testID={testID} + style={({ pressed }) => [pressed && styles.pressed]}> + + + {label} + + + {chosen} + + + + + ({ key: option.value, label: option.label, icon: option.value === value ? 'check' : undefined }))} + cancelLabel={t('common.cancel')} + onClose={() => setOpen(false)} + onSelect={(key) => onChange(key as T)} + /> + + ); +} + +/** A labelled switch with an optional line of explanation. */ +export function SwitchRow({ label, description, value, onChange, first, disabled }: { label: string; description?: string; value: boolean; onChange: (value: boolean) => void; first?: boolean; disabled?: boolean }) { + const { colors } = useTheme(); + return ( + + + {label} + {description ? ( + + {description} + + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + group: { gap: 7 }, + groupLabel: { fontSize: 14, marginLeft: 4 }, + groupCard: { borderWidth: 1, borderRadius: radius.field, overflow: 'hidden' }, + footer: { fontSize: 13, lineHeight: 18, marginLeft: 4 }, + row: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 15, minHeight: 50, paddingVertical: 8 }, + rowLabel: { fontSize: 16.5, flexShrink: 1 }, + rowValue: { flex: 1, fontSize: 16, textAlign: 'right' }, + switchText: { flex: 1, gap: 2 }, + description: { fontSize: 13.5, lineHeight: 18 }, + pressed: { opacity: 0.7 }, +}); diff --git a/apps/mobile/src/components/icon-paths.ts b/apps/mobile/src/components/icon-paths.ts index c3c0a30fd..802e979aa 100644 --- a/apps/mobile/src/components/icon-paths.ts +++ b/apps/mobile/src/components/icon-paths.ts @@ -36,6 +36,12 @@ export const iconPaths = { list: '', map: '', arrowUp: '', + chevronUp: '', + share: '', + more: '', + download: '', + copy: '', + alert: '', } as const; export type IconName = keyof typeof iconPaths; diff --git a/apps/mobile/src/components/markdown-view.tsx b/apps/mobile/src/components/markdown-view.tsx index de2b0a15c..05193fde8 100644 --- a/apps/mobile/src/components/markdown-view.tsx +++ b/apps/mobile/src/components/markdown-view.tsx @@ -20,13 +20,20 @@ export function MarkdownView({ claims = [], streaming, onCite, + size, + tone, }: { source: string; claims?: string[]; streaming?: boolean; onCite?: (n: number) => void; + /** Body text size; documents use a smaller one than Ask's answers. */ + size?: number; + tone?: 'foreground' | 'secondary'; }) { const { colors } = useTheme(); + const body = size ? { fontSize: size, lineHeight: Math.round(size * 1.47) } : null; + const paragraph = [styles.paragraph, body]; const blocks = useMemo(() => attachCitations(parseMarkdown(source), claims), [source, claims]); const caret = ; @@ -76,14 +83,14 @@ export function MarkdownView({ switch (block.type) { case 'paragraph': return ( - + {inline(block.inline)} {tail} ); case 'heading': return ( - + {inline(block.inline)} {tail} @@ -91,7 +98,7 @@ export function MarkdownView({ case 'quote': return ( - + {inline(block.inline)} {tail} @@ -111,10 +118,10 @@ export function MarkdownView({ {block.items.map((item, itemIndex) => ( - + {block.ordered ? `${block.start + itemIndex}.` : '•'} - + {inline(item, `${itemIndex}:`)} {streaming && index === last && itemIndex === block.items.length - 1 ? caret : null} @@ -127,7 +134,7 @@ export function MarkdownView({ return ( - {blocks.length === 0 && streaming ? {caret} : blocks.map((block, index) => {render(block, index)})} + {blocks.length === 0 && streaming ? {caret} : blocks.map((block, index) => {render(block, index)})} ); } diff --git a/apps/mobile/src/components/practice-tools.ts b/apps/mobile/src/components/practice-tools.ts index 64691553a..95349fa22 100644 --- a/apps/mobile/src/components/practice-tools.ts +++ b/apps/mobile/src/components/practice-tools.ts @@ -1,6 +1,6 @@ import type { IconName } from './icon-paths'; -/** The Practice tools, in the website's order. Ask is the first one in the app; the rest follow. */ +/** The Practice tools, in the website's order. Attack and Ask are in the app; the rest follow. */ export const PRACTICE_TOOLS = [ { key: 'attack', icon: 'shield' }, { key: 'ask', icon: 'chat' }, diff --git a/apps/mobile/src/components/prepare-tools.ts b/apps/mobile/src/components/prepare-tools.ts new file mode 100644 index 000000000..3a36b2e34 --- /dev/null +++ b/apps/mobile/src/components/prepare-tools.ts @@ -0,0 +1,11 @@ +import type { IconName } from './icon-paths'; + +/** The Prepare tools, in the website's order. Generate is in the app; the others follow in later updates. */ +export const PREPARE_TOOLS = [ + { key: 'generate', icon: 'sparkles' }, + { key: 'brainstorm', icon: 'bulb' }, + { key: 'factoids', icon: 'book' }, + { key: 'flowchart', icon: 'branch' }, +] as const satisfies readonly { key: string; icon: IconName }[]; + +export type PrepareTool = (typeof PREPARE_TOOLS)[number]['key']; diff --git a/apps/mobile/src/components/tool-form.tsx b/apps/mobile/src/components/tool-form.tsx new file mode 100644 index 000000000..9dce46cda --- /dev/null +++ b/apps/mobile/src/components/tool-form.tsx @@ -0,0 +1,120 @@ +import { useRouter } from 'expo-router'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, StyleSheet, View } from 'react-native'; + +import { TextButton } from './buttons'; +import { Text } from './text'; + +import type { AIErrorKind } from '@/lib/ai-request'; +import { useDebates } from '@/lib/debate-context'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +/** The tool's large title and the line under it. */ +export function ToolTitle({ title, subtitle }: { title: string; subtitle: string }) { + return ( + + + {title} + + + {subtitle} + + + ); +} + +/** Where new work will be saved, as the website's CurrentDebateStrip says it. */ +export function WorkingIn() { + const { t } = useTranslation(); + const { colors } = useTheme(); + const { current, oneOff } = useDebates(); + return ( + + {oneOff ? ( + + {t('debate.oneOffStrip')} + + ) : current ? ( + + {`${t('debate.workingIn')} `} + + {current.motion} + + + ) : ( + + {t('debate.noCurrentStrip')} + + )} + + ); +} + +const PLAN_ERRORS = new Set(['trialExpired', 'limitReached', 'modelRestricted']); + +/** Why a request failed, in the tool's words, with the way forward. */ +export function ToolError({ title, error, onRetry }: { title: string; error: AIErrorKind | 'debate'; onRetry: () => void }) { + const { t } = useTranslation(); + const router = useRouter(); + const { colors } = useTheme(); + return ( + + + {title} + + + {error === 'debate' ? t('debate.saveFailed') : t(`mobile.results.errors.${error}`)} + + + {error !== 'debate' && PLAN_ERRORS.has(error) ? ( + router.navigate('/you')} /> + ) : ( + + )} + + + ); +} + +/** A calm progress state that names what is happening, cycling the website's messages. */ +export function ToolProgress({ messages, detail, onCancel }: { messages: string[]; detail?: string; onCancel: () => void }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const [index, setIndex] = useState(0); + useEffect(() => { + const timer = setInterval(() => setIndex((value) => (value + 1) % messages.length), 3000); + return () => clearInterval(timer); + }, [messages.length]); + return ( + + + + {messages[index]} + + {detail ? ( + + {detail} + + ) : null} + + + ); +} + +const styles = StyleSheet.create({ + titleBlock: { gap: 4, marginBottom: 2 }, + title: { fontSize: 30, lineHeight: 36, letterSpacing: -0.8 }, + subtitle: { fontSize: 16, lineHeight: 22 }, + strip: { borderWidth: 1, borderRadius: radius.field, paddingHorizontal: 14, paddingVertical: 10 }, + dashed: { borderStyle: 'dashed' }, + stripText: { fontSize: 14.5, lineHeight: 20 }, + error: { borderWidth: 1, borderRadius: radius.card, padding: 14, gap: 4 }, + errorTitle: { fontSize: 16 }, + errorBody: { fontSize: 15, lineHeight: 21 }, + errorActions: { flexDirection: 'row', gap: 20 }, + progress: { borderWidth: 1, borderStyle: 'dashed', borderRadius: radius.card + 2, paddingVertical: 36, paddingHorizontal: 20, alignItems: 'center', gap: 12, marginTop: 8 }, + progressText: { fontSize: 18, lineHeight: 24, textAlign: 'center' }, + progressDetail: { fontSize: 15, lineHeight: 21, textAlign: 'center' }, +}); diff --git a/apps/mobile/src/components/work-row.tsx b/apps/mobile/src/components/work-row.tsx new file mode 100644 index 000000000..d8811744f --- /dev/null +++ b/apps/mobile/src/components/work-row.tsx @@ -0,0 +1,79 @@ +import type { Href } from 'expo-router'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { IconTile } from './card'; +import { Icon } from './icon'; +import type { IconName } from './icon-paths'; +import { Text } from './text'; + +import { formatWhen } from '@/lib/dates'; +import type { DebateItem, DebateItemKind } from '@/lib/debates'; +import { useTheme } from '@/theme/use-theme'; + +export const KIND_ICONS: Record = { + case: 'sparkles', + attack: 'shield', + brainstorm: 'bulb', + factoids: 'book', + chat: 'chat', + flowchart: 'branch', + crossfire: 'help', + sparring: 'swords', +}; + +/** The tab a saved result's document goes back to. */ +export type OpenedFrom = 'debate' | 'prepare' | 'practice'; + +/** + * Where a saved item opens. Cases and attacks open read-only as documents; + * the Prepare tools not in the app yet open their "on its way" screen, and + * Practice work opens the Practice tab. + */ +export function workHref(item: DebateItem, from: OpenedFrom): Href { + switch (item.kind) { + case 'case': + return { pathname: '/case/[id]', params: { id: item.id, from } }; + case 'attack': + return { pathname: '/attack/[id]', params: { id: item.id, from } }; + case 'brainstorm': + case 'factoids': + case 'flowchart': + return { pathname: '/coming/[tool]', params: { tool: item.kind } }; + default: + return '/practice'; + } +} + +/** One saved result or round, with its kind and when it was made. */ +export function WorkRow({ item, first, onPress }: { item: DebateItem; first: boolean; onPress: () => void }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + return ( + [styles.row, !first && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> + + + + {item.title || t(`debate.kinds.${item.kind}`)} + + + {t('mobile.debate.itemMeta', { kind: t(`debate.kinds.${item.kind}`), when: formatWhen(item.createdAt) })} + + + + + ); +} + +const styles = StyleSheet.create({ + row: { flexDirection: 'row', alignItems: 'center', gap: 13, paddingVertical: 12, paddingHorizontal: 14 }, + rowText: { flex: 1, gap: 2 }, + rowTitle: { fontSize: 16 }, + rowMeta: { fontSize: 14 }, + pressed: { opacity: 0.7 }, +}); diff --git a/apps/mobile/src/lib/back-label.ts b/apps/mobile/src/lib/back-label.ts new file mode 100644 index 000000000..c21bf44b5 --- /dev/null +++ b/apps/mobile/src/lib/back-label.ts @@ -0,0 +1,7 @@ +import type { TFunction } from 'i18next'; + +/** The back button names the tab a document was opened from, as mock 03's "Prepare". */ +export function backLabel(t: TFunction, from: string | undefined, fallback: 'debate' | 'prepare' | 'practice'): string { + const tab = from === 'debate' || from === 'prepare' || from === 'practice' ? from : from ? null : fallback; + return tab ? t(`mobile.tabs.${tab}`) : t('common.back'); +} diff --git a/apps/mobile/src/lib/confirm.ts b/apps/mobile/src/lib/confirm.ts index a12483994..226e6ef97 100644 --- a/apps/mobile/src/lib/confirm.ts +++ b/apps/mobile/src/lib/confirm.ts @@ -9,3 +9,18 @@ export function confirmDestructive(options: { title: string; message: string; co ], { cancelable: true, onDismiss: () => resolve(false) }); }); } + +/** A native alert with one button, for a short outcome the student should notice. */ +export function showNotice(title: string, message?: string) { + Alert.alert(title, message); +} + +/** iOS's text prompt. Resolves the entered text, or null when cancelled. */ +export function promptText(options: { title: string; message: string; defaultValue: string; confirm: string; cancel: string }): Promise { + return new Promise((resolve) => { + Alert.prompt(options.title, options.message, [ + { text: options.cancel, style: 'cancel', onPress: () => resolve(null) }, + { text: options.confirm, onPress: (value?: string) => resolve(value ?? '') }, + ], 'plain-text', options.defaultValue); + }); +} diff --git a/apps/mobile/src/lib/confirm.web.ts b/apps/mobile/src/lib/confirm.web.ts index 2aeeea491..ca5014496 100644 --- a/apps/mobile/src/lib/confirm.web.ts +++ b/apps/mobile/src/lib/confirm.web.ts @@ -2,3 +2,11 @@ export function confirmDestructive(options: { title: string; message: string; confirm: string; cancel: string }): Promise { return Promise.resolve(window.confirm(`${options.title}\n\n${options.message}`)); } + +export function showNotice(title: string, message?: string) { + window.alert(message ? `${title}\n\n${message}` : title); +} + +export function promptText(options: { title: string; message: string; defaultValue: string; confirm: string; cancel: string }): Promise { + return Promise.resolve(window.prompt(`${options.title}\n\n${options.message}`, options.defaultValue)); +} diff --git a/apps/mobile/src/lib/debates.ts b/apps/mobile/src/lib/debates.ts index d3c63de72..97fa167b9 100644 --- a/apps/mobile/src/lib/debates.ts +++ b/apps/mobile/src/lib/debates.ts @@ -192,6 +192,25 @@ export interface DebateItem { createdAt: number; } +async function fetchResultRows(userId: string, debateId: string, table: ResultTable, sides: string[] | null, limit: number): Promise { + let query = supabase + .from(table) + .select('id,title,created_at') + .eq('user_id', userId) + .eq('debate_id', debateId); + if (sides) query = query.in('side', sides); + const { data, error } = await query.order('created_at', { ascending: false }).limit(limit); + if (error) throw new Error(error.message); + return ((data ?? []) as { id: string; title: string; created_at: string }[]).map((row): DebateItem => ({ + id: row.id, + kind: KIND_BY_TABLE[table], + title: row.title, + createdAt: new Date(row.created_at).getTime(), + })); +} + +const newestFirst = (lists: DebateItem[][], limit: number) => lists.flat().sort((a, b) => b.createdAt - a.createdAt).slice(0, limit); + /** The newest saved results and rounds for one side of a debate, like the website's side lists. */ export async function fetchRecentWork( userId: string, @@ -200,23 +219,7 @@ export async function fetchRecentWork( limit: number, ): Promise { const lists = await Promise.all([ - ...RESULT_TABLES.map(async (table) => { - const { data, error } = await supabase - .from(table) - .select('id,title,created_at') - .eq('user_id', userId) - .eq('debate_id', debateId) - .in('side', SIDES_FOR[side]) - .order('created_at', { ascending: false }) - .limit(limit); - if (error) throw new Error(error.message); - return ((data ?? []) as { id: string; title: string; created_at: string }[]).map((row): DebateItem => ({ - id: row.id, - kind: KIND_BY_TABLE[table], - title: row.title, - createdAt: new Date(row.created_at).getTime(), - })); - }), + ...RESULT_TABLES.map((table) => fetchResultRows(userId, debateId, table, SIDES_FOR[side], limit)), (async () => { // The website lists a round under its motion and the side the student spoke, ordered by its last update. const { data, error } = await supabase @@ -236,5 +239,14 @@ export async function fetchRecentWork( })); })(), ]); - return lists.flat().sort((a, b) => b.createdAt - a.createdAt).slice(0, limit); + return newestFirst(lists, limit); +} + +/** The Prepare tools' tables. */ +const PREPARE_TABLES = ['cases', 'brainstorms', 'factoids', 'flowcharts'] as const satisfies readonly ResultTable[]; + +/** A debate's saved Prepare results for both sides, newest first, for the Prepare tab. */ +export async function fetchPrepareWork(userId: string, debateId: string, limit: number): Promise { + const lists = await Promise.all(PREPARE_TABLES.map((table) => fetchResultRows(userId, debateId, table, null, limit))); + return newestFirst(lists, limit); } diff --git a/apps/mobile/src/lib/export-html.ts b/apps/mobile/src/lib/export-html.ts new file mode 100644 index 000000000..bb07b6411 --- /dev/null +++ b/apps/mobile/src/lib/export-html.ts @@ -0,0 +1,126 @@ +import type { AttackOutput } from '@debatelab/core/ai/schemas/attack'; +import type { GenerateOutput } from '@debatelab/core/ai/schemas/generate'; + +type Translate = (key: string, options?: Record) => string; + +/** + * The PDF pages for expo-print, as HTML. They carry the same sections and + * labels as the website's PDF export (src/lib/pdf-export-utils.ts) and keep + * its file names, in DebateLab's colours. + */ + +function escape(value: string | undefined | null): string { + return (value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]!); +} + +const STYLE = ` + @page { margin: 18mm 16mm; } + body { font-family: -apple-system, 'Inter', 'Helvetica Neue', Arial, sans-serif; color: #0f172a; font-size: 11pt; line-height: 1.5; margin: 0; } + header { border-bottom: 3px solid #0284c7; padding-bottom: 10px; margin-bottom: 18px; } + h1 { font-size: 20pt; line-height: 1.2; margin: 0 0 4px; letter-spacing: -0.02em; } + .meta { color: #64748b; font-size: 10pt; } + h2 { font-size: 13pt; margin: 22px 0 8px; padding-bottom: 4px; border-bottom: 1px solid #e2e8f0; color: #0f172a; } + h3 { font-size: 11pt; margin: 12px 0 4px; } + p { margin: 0 0 8px; } + .label { font-weight: 700; color: #475569; } + .framing { background: #e0f2fe; border: 1px solid #bae6fd; border-radius: 10px; padding: 10px 14px; margin-bottom: 14px; color: #0c4a6e; } + .framing .label { color: #075985; } + .block { border-left: 3px solid #0284c7; padding: 2px 0 2px 12px; margin: 0 0 14px; break-inside: avoid; } + .block h3 { margin-top: 0; } + .number { color: #0284c7; margin-right: 6px; } + ul { margin: 0 0 8px 18px; padding: 0; } + li { margin-bottom: 3px; } + .tag { display: inline-block; border: 1px solid #cbd5e1; border-radius: 6px; padding: 0 6px; font-size: 9pt; font-weight: 700; margin-right: 4px; } + .critical { color: #dc2626; border-color: #dc2626; } + .major { color: #d97706; border-color: #d97706; } + .minor { color: #0284c7; border-color: #0284c7; } + footer { margin-top: 26px; color: #94a3b8; font-size: 9pt; text-align: center; } +`; + +function page(title: string, meta: string, body: string, t: Translate): string { + return `${escape(title)}

${escape(title)}

${escape(meta)}
${body}
${escape(t('export.generatedBy'))}
`; +} + +const paragraph = (label: string, text: string) => `

${escape(label)}: ${escape(text)}

`; +const bullets = (items: string[]) => (items.length ? `
    ${items.map((item) => `
  • ${escape(item)}
  • `).join('')}
` : ''); + +export function caseExportFileName(result: GenerateOutput): string { + const side = result.metadata?.side === 'pro' ? 'pro' : result.metadata?.side === 'con' ? 'con' : 'unknown'; + return `DebateCase_${side}.pdf`; +} + +export function attackExportFileName(result: AttackOutput): string { + return `AttackAnalysis_${result.overallAssessment?.score || 0}.pdf`; +} + +export function caseExportHtml(result: GenerateOutput, t: Translate): string { + const side = result.metadata?.side === 'pro' ? t('export.proposition') : result.metadata?.side === 'con' ? t('export.opposition') : t('export.unknown'); + const format = result.metadata?.format?.toUpperCase() || t('export.standard'); + const title = result.metadata?.motion || t('export.debateCase'); + const parts: string[] = []; + + parts.push(`

${escape(t('export.thesis'))}: ${escape(result.caseStructure?.thesis || t('export.noThesis'))}

${ + result.caseStructure?.framework ? `

${escape(t('export.framework'))}: ${escape(result.caseStructure.framework)}

` : '' + }
`); + if (result.exordio && (result.exordio.hook || result.exordio.transition)) { + parts.push(`

${escape(t('export.exordio'))}

`); + if (result.exordio.hook) parts.push(paragraph(t('export.hook'), result.exordio.hook)); + if (result.exordio.transition) parts.push(paragraph(t('export.transition'), result.exordio.transition)); + } + if (result.caseStructure?.burdenOfProof) parts.push(paragraph(t('export.burdenOfProof'), result.caseStructure.burdenOfProof)); + if (result.caseStructure?.keyDefinitions?.length) parts.push(`

${escape(t('export.keyDefinitions'))}

${bullets(result.caseStructure.keyDefinitions)}`); + + parts.push(`

${escape(t('export.arguments'))}

`); + if (!result.contentions?.length) parts.push(`

${escape(t('export.noArguments'))}

`); + result.contentions?.forEach((c, i) => { + parts.push(`

${escape(t('export.contentionNumber', { number: i + 1 }))}${escape(c.claim)}

${ + c.reasoning ? paragraph(t('export.reasoning'), c.reasoning) : '' + }${c.evidence ? paragraph(t('export.evidence'), c.evidence) : ''}${c.impact ? paragraph(t('export.impact'), c.impact) : ''}
`); + }); + + parts.push(`

${escape(t('export.predictedOpponent'))}

`); + parts.push(result.predictedOpponentCase?.likelyArguments?.length ? bullets(result.predictedOpponentCase.likelyArguments) : `

${escape(t('export.noOpponentArgs'))}

`); + + parts.push(`

${escape(t('export.counters'))}

`); + if (!result.counters?.length) parts.push(`

${escape(t('export.noCounters'))}

`); + result.counters?.forEach((c) => { + parts.push(`

${escape(t('export.target'))}: ${escape(c.targetArgument)}

${ + c.counterpoint ? paragraph(t('export.counter'), c.counterpoint) : '' + }${c.rebuttal ? paragraph(t('export.rebuttal'), c.rebuttal) : ''}
`); + }); + + return page(title, `${side} • ${format} ${t('export.format')}`, parts.join(''), t); +} + +export function attackExportHtml(result: AttackOutput, t: Translate): string { + const score = result.overallAssessment?.score || 0; + const parts: string[] = []; + parts.push(`

${escape(t('export.assessmentSummary'))}

${escape(result.overallAssessment?.summary || t('export.noSummary'))}

`); + if (result.overallAssessment?.strengthAreas?.length) { + parts.push(`

${escape(t('export.strengthAreas'))}

${bullets(result.overallAssessment.strengthAreas)}`); + } + parts.push(`

${escape(t('export.weaknesses'))}

`); + if (!result.weaknesses?.length) parts.push(`

${escape(t('export.noWeaknesses'))}

`); + result.weaknesses?.forEach((w, i) => { + const severity = ['critical', 'major', 'minor'].includes(w.severity) ? w.severity : 'minor'; + parts.push(`

${i + 1}.${escape(w.severity?.toUpperCase())}${escape(w.type)} ${escape(w.description)}

${ + w.affectedQuote ? `

"${escape(w.affectedQuote)}"

` : '' + }${paragraph(t('export.whyWeak'), w.whyWeak || '')}${paragraph(t('export.howOpponentAttacks'), w.howOpponentAttacks || '')}${paragraph(t('export.improvedVersion'), w.improvedVersion || '')}
`); + }); + parts.push(`

${escape(t('export.fallacies'))}

`); + if (!result.fallaciesDetected?.length) parts.push(`

${escape(t('export.noFallacies'))}

`); + result.fallaciesDetected?.forEach((f) => { + parts.push(`

${escape(f.name)}

${paragraph(t('export.location'), f.location)}

${escape(f.explanation)}

`); + }); + parts.push(`

${escape(t('export.crossExQuestions'))}

`); + if (!result.crossExQuestions?.length) parts.push(`

${escape(t('export.noCrossEx'))}

`); + result.crossExQuestions?.forEach((q, i) => { + parts.push(`

Q${i + 1}: ${escape(q.question)}

${paragraph(t('export.purpose'), q.purpose)}${paragraph(t('export.expectedTrap'), q.expectedTrap)}
`); + }); + if (result.weighingAnalysis) { + parts.push(`

${escape(t('export.weighingAnalysis'))}

${escape(result.weighingAnalysis.currentWeighing || t('export.noWeighingAnalysis'))}

`); + if (result.weighingAnalysis.missingComparisons?.length) parts.push(`

${escape(t('export.missingComparisons'))}

${bullets(result.weighingAnalysis.missingComparisons)}`); + if (result.weighingAnalysis.improvementSuggestions?.length) parts.push(`

${escape(t('export.improvementSuggestions'))}

${bullets(result.weighingAnalysis.improvementSuggestions)}`); + } + return page(t('export.caseAttack'), `${t('export.overallScore')}: ${score}/100`, parts.join(''), t); +} diff --git a/apps/mobile/src/lib/export.ts b/apps/mobile/src/lib/export.ts new file mode 100644 index 000000000..839e6544e --- /dev/null +++ b/apps/mobile/src/lib/export.ts @@ -0,0 +1,26 @@ +import * as Clipboard from 'expo-clipboard'; +import { File, Paths } from 'expo-file-system'; +import * as Print from 'expo-print'; +import * as Sharing from 'expo-sharing'; +import { Share } from 'react-native'; + +/** + * Export on the phone: expo-print renders the document's HTML to a PDF, + * which is renamed to the website's file name and handed to the share sheet. + */ +export async function sharePdf(html: string, fileName: string, dialogTitle: string): Promise { + const { uri } = await Print.printToFileAsync({ html }); + const target = new File(Paths.cache, fileName); + if (target.exists) target.delete(); + await new File(uri).move(target); + await Sharing.shareAsync(target.uri, { mimeType: 'application/pdf', UTI: 'com.adobe.pdf', dialogTitle }); +} + +export async function copyText(text: string): Promise { + await Clipboard.setStringAsync(text); +} + +/** The share button: the document as plain text, through the iOS share sheet. */ +export async function shareText(title: string, text: string): Promise { + await Share.share({ title, message: text }); +} diff --git a/apps/mobile/src/lib/export.web.ts b/apps/mobile/src/lib/export.web.ts new file mode 100644 index 000000000..894085a3b --- /dev/null +++ b/apps/mobile/src/lib/export.web.ts @@ -0,0 +1,29 @@ +import * as Clipboard from 'expo-clipboard'; + +/** + * The web preview cannot make a PDF: expo-print's printToFileAsync does not + * exist there. It keeps the HTML it would print and the text it copied on + * window, so the screenshot script can show and check them. + */ +type ExportProbe = { __debatelabExport?: { html?: string; fileName?: string; copied?: string; shared?: string } }; + +function probe() { + const target = window as unknown as ExportProbe; + target.__debatelabExport ??= {}; + return target.__debatelabExport; +} + +export function sharePdf(html: string, fileName: string): Promise { + Object.assign(probe(), { html, fileName }); + return Promise.resolve(); +} + +export async function copyText(text: string): Promise { + probe().copied = text; + await Clipboard.setStringAsync(text).catch((): undefined => undefined); +} + +export function shareText(_title: string, text: string): Promise { + probe().shared = text; + return Promise.resolve(); +} diff --git a/apps/mobile/src/lib/motion-gate.ts b/apps/mobile/src/lib/motion-gate.ts new file mode 100644 index 000000000..a90b36981 --- /dev/null +++ b/apps/mobile/src/lib/motion-gate.ts @@ -0,0 +1,30 @@ +import { Alert } from 'react-native'; + +import type { Debate } from './debates'; + +export type MotionChoice = { kind: 'switch'; debateId: string } | { kind: 'create' } | { kind: 'one-off' } | null; + +export interface MotionGateText { + title: string; + message: string; + switchLabel: string; + createLabel: string; + onceLabel: string; + cancel: string; +} + +/** + * The website's DebateMotionGate as a native alert: a motion other than the + * Current Debate's asks whether to switch to a debate with that motion, + * create one, or use the motion once. Resolves null when cancelled. + */ +export function chooseMotionTarget(matches: Debate[], text: MotionGateText): Promise { + return new Promise((resolve) => { + Alert.alert(text.title, text.message, [ + ...matches.map((debate) => ({ text: text.switchLabel, onPress: () => resolve({ kind: 'switch' as const, debateId: debate.id }) })), + { text: text.createLabel, onPress: () => resolve({ kind: 'create' }) }, + { text: text.onceLabel, onPress: () => resolve({ kind: 'one-off' }) }, + { text: text.cancel, style: 'cancel', onPress: () => resolve(null) }, + ], { cancelable: true, onDismiss: () => resolve(null) }); + }); +} diff --git a/apps/mobile/src/lib/motion-gate.web.ts b/apps/mobile/src/lib/motion-gate.web.ts new file mode 100644 index 000000000..e47ff6dc5 --- /dev/null +++ b/apps/mobile/src/lib/motion-gate.web.ts @@ -0,0 +1,11 @@ +import type { Debate } from './debates'; +import type { MotionChoice, MotionGateText } from './motion-gate'; + +/** react-native-web's Alert does nothing, so the web preview asks with the browser's dialogs. */ +export function chooseMotionTarget(matches: Debate[], text: MotionGateText): Promise { + const ask = (label: string) => window.confirm(`${text.title}\n\n${text.message}\n\n${label}?`); + if (matches[0] && ask(text.switchLabel)) return Promise.resolve({ kind: 'switch', debateId: matches[0].id }); + if (ask(text.createLabel)) return Promise.resolve({ kind: 'create' }); + if (ask(text.onceLabel)) return Promise.resolve({ kind: 'one-off' }); + return Promise.resolve(null); +} diff --git a/apps/mobile/src/lib/motion.ts b/apps/mobile/src/lib/motion.ts new file mode 100644 index 000000000..861826203 --- /dev/null +++ b/apps/mobile/src/lib/motion.ts @@ -0,0 +1,40 @@ +import type { Debate } from './debates'; + +/** + * The website's motion rules (src/lib/debate/motion.ts and normalizeMotion in + * src/lib/stores/debate-store.ts): which debate a motion-bearing tool's work + * belongs to, and when a form's motion follows the Current Debate. + */ + +export function normalizeMotion(motion: string): string { + return motion.trim().replace(/\s+/g, ' ').toLocaleLowerCase(); +} + +export function sameMotion(a: string | undefined | null, b: string | undefined | null): boolean { + return Boolean(a && b) && normalizeMotion(a!) === normalizeMotion(b!); +} + +export type MotionDecision = + | { kind: 'attach'; debateId: string } + | { kind: 'one-off' } + /** A different (or first) motion: the student chooses Switch, Create, or Use once. */ + | { kind: 'ask'; matches: Debate[] }; + +/** Only an exact motion match attaches silently; anything else asks. */ +export function decideMotionTarget(motion: string, state: { debates: Debate[]; current: Debate | null; oneOff: boolean }): MotionDecision { + if (state.oneOff) return { kind: 'one-off' }; + const { current } = state; + if (current && sameMotion(current.motion, motion)) return { kind: 'attach', debateId: current.id }; + return { + kind: 'ask', + matches: state.debates.filter((debate) => debate.id !== current?.id && sameMotion(debate.motion, motion)), + }; +} + +/** Fill the motion from the Current Debate only when the field is empty or still holds another debate's motion. */ +export function shouldPrefillMotion(formMotion: string | undefined, debateMotion: string, knownMotions: string[]): boolean { + const current = normalizeMotion(formMotion ?? ''); + if (!current) return true; + if (current === normalizeMotion(debateMotion)) return false; + return knownMotions.some((motion) => normalizeMotion(motion) === current); +} diff --git a/apps/mobile/src/lib/open-results.ts b/apps/mobile/src/lib/open-results.ts new file mode 100644 index 000000000..bba9d1432 --- /dev/null +++ b/apps/mobile/src/lib/open-results.ts @@ -0,0 +1,45 @@ +import type { DiscoveredSource } from '@debatelab/core/ai/discovered-sources'; + +import type { OutputFor, ResultKind } from './results'; + +/** + * Results produced in this session, so their document opens at once and still + * opens when nothing was saved: one-off work, or a save that failed. The + * researched sources live only here, as they do on the website's page. + */ +export interface OpenResult { + kind: K; + /** The saved row, or null while the result is not saved anywhere. */ + savedId: string | null; + title: string; + side: string | null; + debateId: string | null; + createdAt: number; + input: Record; + output: OutputFor[K]; + sources: DiscoveredSource[]; +} + +const results = new Map(); +let localId = 0; + +/** Keeps a result under its saved id, or a local key when it is not saved. Returns the key to open it with. */ +export function keepOpenResult(result: OpenResult): string { + const key = result.savedId ?? `local-${Date.now()}-${(localId += 1)}`; + results.set(key, result as OpenResult); + return key; +} + +export function openResult(key: string, kind: K): OpenResult | undefined { + const result = results.get(key); + return result?.kind === kind ? (result as OpenResult) : undefined; +} + +export function updateOpenResult(key: string, patch: Partial>) { + const result = results.get(key); + if (result) results.set(key, { ...result, ...patch }); +} + +export function forgetOpenResult(key: string) { + results.delete(key); +} diff --git a/apps/mobile/src/lib/result-text.ts b/apps/mobile/src/lib/result-text.ts new file mode 100644 index 000000000..588e0cb84 --- /dev/null +++ b/apps/mobile/src/lib/result-text.ts @@ -0,0 +1,93 @@ +import type { AttackOutput } from '@debatelab/core/ai/schemas/attack'; +import type { GenerateOutput } from '@debatelab/core/ai/schemas/generate'; + +type Translate = (key: string, options?: Record) => string; + +/** A case as plain text, line for line as the website's Clipboard button copies it (GenerateResults). */ +export function caseToText(result: GenerateOutput): string { + const lines: string[] = []; + lines.push(`THESIS: ${result.caseStructure?.thesis || ''}`); + lines.push(`FRAMEWORK: ${result.caseStructure?.framework || ''}`); + lines.push(''); + result.contentions?.forEach((c, i) => { + lines.push(`CONTENTION #${i + 1}: ${c.claim}`); + lines.push(` Reasoning: ${c.reasoning}`); + lines.push(` Evidence: ${c.evidence}`); + lines.push(` Impact: ${c.impact}`); + lines.push(''); + }); + if (result.counters?.length) { + lines.push('PREPARED COUNTERS:'); + result.counters.forEach((c) => { + lines.push(` vs "${c.targetArgument}": ${c.counterpoint}`); + }); + } + return lines.join('\n'); +} + +/** + * An attack analysis as plain text. The website has no clipboard button for + * attacks, so this follows its Word export's sections and labels. + */ +export function attackToText(result: AttackOutput, t: Translate): string { + const lines: string[] = [t('export.caseAttack'), '']; + lines.push(`${t('export.overallScore')}: ${result.overallAssessment.score}/100`); + if (result.overallAssessment.summary) lines.push(result.overallAssessment.summary); + if (result.overallAssessment.strengthAreas?.length) { + lines.push('', `${t('export.strengthAreas')}:`); + result.overallAssessment.strengthAreas.forEach((item) => lines.push(` - ${item}`)); + } + lines.push('', `${t('export.weaknesses').toUpperCase()}:`); + if (result.weaknesses.length === 0) lines.push(` ${t('export.noWeaknesses')}`); + result.weaknesses.forEach((w, i) => { + lines.push(`${i + 1}. [${w.severity?.toUpperCase()}] ${w.description}`); + lines.push(` ${t('export.whyWeak')}: ${w.whyWeak || ''}`); + lines.push(` ${t('export.howOpponentAttacks')}: ${w.howOpponentAttacks || ''}`); + lines.push(` ${t('export.improvedVersion')}: ${w.improvedVersion || ''}`); + }); + lines.push('', `${t('export.fallacies').toUpperCase()}:`); + if ((result.fallaciesDetected ?? []).length === 0) lines.push(` ${t('export.noFallacies')}`); + (result.fallaciesDetected ?? []).forEach((f) => { + lines.push(`${f.name} (${t('export.location')}: ${f.location})`); + lines.push(` ${f.explanation}`); + }); + lines.push('', `${t('export.crossExQuestions').toUpperCase()}:`); + if ((result.crossExQuestions ?? []).length === 0) lines.push(` ${t('export.noCrossEx')}`); + (result.crossExQuestions ?? []).forEach((q, i) => { + lines.push(`Q${i + 1}: ${q.question}`); + lines.push(` ${t('export.purpose')}: ${q.purpose}`); + lines.push(` ${t('export.expectedTrap')}: ${q.expectedTrap}`); + }); + if (result.weighingAnalysis) { + lines.push('', `${t('export.weighingAnalysis').toUpperCase()}:`); + lines.push(result.weighingAnalysis.currentWeighing || t('export.noWeighingAnalysis')); + (result.weighingAnalysis.missingComparisons ?? []).forEach((item) => lines.push(` - ${item}`)); + (result.weighingAnalysis.improvementSuggestions ?? []).forEach((item) => lines.push(` → ${item}`)); + } + return lines.join('\n'); +} + +/** The message the website's Discuss button sends to Ask for a case (GeneratePage). */ +export function discussCaseMessage(result: GenerateOutput): string { + return `I'd like to discuss the case we just generated. + + **Thesis:** ${result.caseStructure.thesis} + + **Arguments:** + ${result.contentions.map((c, i) => `${i + 1}. ${c.claim}`).join('\n')} + + Can we refine this?`; +} + +/** The message the website's Discuss button sends to Ask for an attack (AttackPage). */ +export function discussAttackMessage(result: AttackOutput): string { + return `I'd like to discuss the critique of my case. + + **Overall Assessment:** ${result.overallAssessment.summary} + **Score:** ${result.overallAssessment.score}/100 + + **Top Weaknesses:** + ${result.weaknesses.slice(0, 3).map((w) => `- ${w.description} (${w.severity})`).join('\n')} + + Can you help me improve this?`; +} diff --git a/apps/mobile/src/lib/results.ts b/apps/mobile/src/lib/results.ts new file mode 100644 index 000000000..ab9cac7c1 --- /dev/null +++ b/apps/mobile/src/lib/results.ts @@ -0,0 +1,209 @@ +import { + AUTO_SAVED_FOLDER, + autoSavedSourceRow, + parseDiscoveredSources, + retainedDiscoveredSources, + type DiscoveredSource, + type ResultWithSources, +} from '@debatelab/core/ai/discovered-sources'; +import { expectedContentionCount, transformGenerateResponse } from '@debatelab/core/ai/generate-output'; +import { AttackOutputSchema, type AttackInput, type AttackOutput } from '@debatelab/core/ai/schemas/attack'; +import { GenerateOutputSchema, type GenerateInput, type GenerateOutput } from '@debatelab/core/ai/schemas/generate'; +import type { DebateContextRequest } from '@debatelab/core/contract/debate-context'; + +import { AIError, functionOutput, postFunction, PROVIDER } from './ai-request'; +import type { FeatureConfig } from './feature-config'; +import { addSource, fetchSourceIdsInFolder, removeSource } from './sources'; +import { supabase } from './supabase'; + +/** + * Generate and Attack, sent and saved the way the website does it: + * generateCase and attackCase in src/lib/ai/client.ts for the requests and + * their researched sources, and recordResult and saveCurrentCase in + * src/lib/stores/session-store.ts for the saved rows. + */ + +export type ResultKind = 'case' | 'attack'; + +const TABLES = { case: 'cases', attack: 'attacks' } as const satisfies Record; + +export interface OutputFor { + case: GenerateOutput; + attack: AttackOutput; +} + +export interface SavedResult { + id: string; + kind: K; + title: string; + side: string | null; + debateId: string | null; + createdAt: number; + input: Record; + output: OutputFor[K]; +} + +interface RequestOptions { + config: FeatureConfig; + language: string; + /** Absent for one-off work. */ + debateContext?: DebateContextRequest; + signal?: AbortSignal; +} + +function requestBody(feature: 'generate' | 'attack', input: Record, options: RequestOptions) { + return { + feature, + input: { ...input, language: options.language }, + provider: PROVIDER, + model: options.config.model, + temperature: options.config.temperature, + reasoningEffort: options.config.reasoningEffort, + // Servers from before Current Debate context read only this field. + sourceIds: options.debateContext?.sourceIds ?? [], + ...(options.debateContext ? { debateContext: options.debateContext } : {}), + }; +} + +/** A generated case, checked like the website checks it and stamped with the local time and motion. */ +export function generateCase(input: GenerateInput, options: RequestOptions): Promise> { + return postFunction('ai', requestBody('generate', input, options), options.signal, async (response) => { + const { output, data } = await functionOutput(response); + const parsed = GenerateOutputSchema.safeParse(transformGenerateResponse(output as Record)); + if (!parsed.success) throw new AIError('failed', 'AI response did not match the expected generate schema.'); + const result = parsed.data; + if (result.contentions.length !== expectedContentionCount(input.length)) { + throw new AIError('failed', 'AI response did not contain the expected number of contentions.'); + } + result.metadata.generatedAt = new Date().toISOString(); + result.metadata.motion = input.motion; + return { result, discoveredSources: parseDiscoveredSources(data.discoveredSources) }; + }); +} + +export function attackCase(input: AttackInput, options: RequestOptions): Promise> { + return postFunction('ai', requestBody('attack', input, options), options.signal, async (response) => { + const { output, data } = await functionOutput(response); + const parsed = AttackOutputSchema.safeParse(output); + if (!parsed.success) throw new AIError('failed', 'AI response did not match the expected attack schema.'); + return { result: parsed.data, discoveredSources: parseDiscoveredSources(data.discoveredSources) }; + }); +} + +/** + * Keeps the researched sources worth keeping in the debate's Auto-saved + * folder, one at a time, as the website does. One-off research is not kept. + * A source that fails to save is skipped; the count of failures is returned. + */ +export async function autosaveSources(userId: string, sources: DiscoveredSource[], capturedVia: 'generate' | 'attack', debateId: string | null): Promise<{ saved: number; failed: number }> { + if (!debateId) return { saved: 0, failed: 0 }; + let saved = 0; + let failed = 0; + for (const source of retainedDiscoveredSources(sources)) { + try { + await addSource(userId, { ...autoSavedSourceRow(source, capturedVia), debateId }); + saved += 1; + } catch (cause) { + console.warn('Could not save a researched source:', cause instanceof Error ? cause.message : cause); + failed += 1; + } + } + return { saved, failed }; +} + +/** The website's rule for its undo: every source in the debate's Auto-saved folder. */ +export function fetchAutoSavedSourceIds(userId: string, debateId: string): Promise { + return fetchSourceIdsInFolder(userId, debateId, AUTO_SAVED_FOLDER); +} + +export async function removeSources(userId: string, ids: string[]): Promise<{ succeeded: number; failed: number }> { + const results = await Promise.allSettled(ids.map((id) => removeSource(userId, id))); + const succeeded = results.filter((result) => result.status === 'fulfilled').length; + return { succeeded, failed: results.length - succeeded }; +} + +/** + * Every read and write below also filters on the user. Row level security is + * the real boundary; the filter keeps a wrong session from mixing accounts. + */ + +/** Saves a result into its debate, as the website's recordResult. Returns the new row's id. */ +export async function recordResult( + userId: string, + kind: K, + record: { title: string; input: unknown; output: OutputFor[K]; debateId: string; side: string }, +): Promise { + const { data, error } = await supabase + .from(TABLES[kind]) + .insert({ + user_id: userId, + debate_id: record.debateId, + side: record.side, + title: record.title.slice(0, 300) || 'Untitled', + input: record.input ?? {}, + output: record.output, + }) + .select('id') + .single(); + if (error || !data) throw new Error(error?.message ?? `Could not save the ${kind}`); + return (data as { id: string }).id; +} + +/** Saves a case outside any debate, as the website's Save button does for unrecorded work. */ +export async function saveCase(userId: string, title: string, output: GenerateOutput): Promise { + const { data, error } = await supabase + .from('cases') + .insert({ user_id: userId, title, input: {}, output }) + .select('id') + .single(); + if (error || !data) throw new Error(error?.message ?? 'Could not save the case'); + return (data as { id: string }).id; +} + +interface ResultRow { + id: string; + title: string; + side: string | null; + debate_id: string | null; + created_at: string; + input: unknown; + output: unknown; +} + +export async function fetchResult(userId: string, kind: K, id: string): Promise | null> { + const { data, error } = await supabase + .from(TABLES[kind]) + .select('id,title,side,debate_id,created_at,input,output') + .eq('user_id', userId) + .eq('id', id) + .maybeSingle(); + if (error) throw new Error(error.message); + if (!data) return null; + const row = data as ResultRow; + return { + id: row.id, + kind, + title: row.title, + side: row.side, + debateId: row.debate_id, + createdAt: new Date(row.created_at).getTime(), + input: row.input && typeof row.input === 'object' && !Array.isArray(row.input) ? (row.input as Record) : {}, + output: row.output as OutputFor[K], + }; +} + +export async function deleteResult(userId: string, kind: ResultKind, id: string): Promise { + const { error } = await supabase.from(TABLES[kind]).delete().eq('user_id', userId).eq('id', id); + if (error) throw new Error(error.message); +} + +/** The guard the website's results pages use: an old or broken row shows a notice instead of a crash. */ +export function isCompleteCase(output: unknown): output is GenerateOutput { + const value = output as Partial | null; + return Boolean(value?.caseStructure && Array.isArray(value.contentions) && value.predictedOpponentCase && Array.isArray(value.counters)); +} + +export function isCompleteAttack(output: unknown): output is AttackOutput { + const value = output as Partial | null; + return Boolean(value?.overallAssessment && Array.isArray(value.weaknesses)); +} diff --git a/apps/mobile/src/lib/sources.ts b/apps/mobile/src/lib/sources.ts index f4027fb94..7e57b1942 100644 --- a/apps/mobile/src/lib/sources.ts +++ b/apps/mobile/src/lib/sources.ts @@ -82,14 +82,28 @@ export async function fetchSource(userId: string, id: string): Promise | null; + }, ): Promise { + const folder = input.folder?.trim() || DEFAULT_FOLDER; const folderResult = await supabase .from('source_folders') - .upsert({ user_id: userId, name: DEFAULT_FOLDER }, { onConflict: 'user_id,name' }); + .upsert({ user_id: userId, name: folder }, { onConflict: 'user_id,name' }); if (folderResult.error) throw new Error(folderResult.error.message); const { data, error } = await supabase .from('sources') @@ -100,9 +114,9 @@ export async function addSource( content: input.content, type: input.type, origin: input.origin || null, - folder: DEFAULT_FOLDER, - is_active: true, - metadata: null, + folder, + is_active: input.isActive ?? true, + metadata: input.metadata ?? null, }) .select(SOURCE_COLUMNS) .single(); @@ -111,6 +125,18 @@ export async function addSource( return fromRow(data as SourceRow); } +/** The sources in one debate's folder, as the website's undo for auto-saved research finds them. */ +export async function fetchSourceIdsInFolder(userId: string, debateId: string, folder: string): Promise { + const { data, error } = await supabase + .from('sources') + .select('id') + .eq('user_id', userId) + .eq('debate_id', debateId) + .eq('folder', folder); + if (error) throw new Error(error.message); + return ((data ?? []) as { id: string }[]).map((row) => row.id); +} + export async function removeSource(userId: string, id: string): Promise { const { error } = await supabase.from('sources').delete().eq('user_id', userId).eq('id', id); if (error) throw new Error(error.message); diff --git a/apps/mobile/src/lib/use-result-document.ts b/apps/mobile/src/lib/use-result-document.ts new file mode 100644 index 000000000..5708293c7 --- /dev/null +++ b/apps/mobile/src/lib/use-result-document.ts @@ -0,0 +1,199 @@ +import { useFocusEffect, useRouter } from 'expo-router'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useAuth } from './auth'; +import { confirmDestructive, promptText } from './confirm'; +import { useDebates } from './debate-context'; +import { forgetOpenResult, keepOpenResult, openResult, updateOpenResult, type OpenResult } from './open-results'; +import { deleteResult, fetchAutoSavedSourceIds, fetchResult, removeSources, saveCase, type ResultKind } from './results'; +import { fetchSources, subscribeSourcesChanged, type Source } from './sources'; + +export type DocumentState = + | { status: 'loading' } + | { status: 'missing' } + | { status: 'failed' } + | { status: 'ready'; key: string; result: OpenResult }; + +const NOTICE_MS = 3500; + +/** + * A result's document: the result itself (from this session or the saved + * row), the debate's Library for its citation chips, the Current Debate's + * auto-saved sources for the undo, and the menu's actions. Every action reads + * the account when the student acts, and nothing lands after the screen is + * left or the account changes. + */ +export function useResultDocument(kind: K, id: string) { + const { t } = useTranslation(); + const router = useRouter(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const { current } = useDebates(); + const [state, setState] = useState>(() => { + const cached = openResult(id, kind); + return cached ? { status: 'ready', key: id, result: cached } : { status: 'loading' }; + }); + const [library, setLibrary] = useState([]); + const [autoSaved, setAutoSaved] = useState([]); + const [notice, setNoticeState] = useState(''); + const [busy, setBusy] = useState(false); + const live = useRef(true); + const owner = useRef(userId); + owner.current = userId; + const noticeTimer = useRef | null>(null); + + useEffect(() => () => { + live.current = false; + if (noticeTimer.current) clearTimeout(noticeTimer.current); + }, []); + + const setNotice = useCallback((text: string) => { + if (!live.current) return; + setNoticeState(text); + if (noticeTimer.current) clearTimeout(noticeTimer.current); + noticeTimer.current = setTimeout(() => { + if (live.current) setNoticeState(''); + }, NOTICE_MS); + }, []); + + // Load the saved row unless this session already holds the result. + useEffect(() => { + if (openResult(id, kind)) return; + if (!userId || id.startsWith('local-')) { + setState({ status: id.startsWith('local-') ? 'missing' : 'loading' }); + return; + } + let active = true; + fetchResult(userId, kind, id) + .then((row) => { + if (!active) return; + if (!row) { + setState({ status: 'missing' }); + return; + } + const result: OpenResult = { ...row, savedId: row.id, sources: [] }; + keepOpenResult(result); + setState({ status: 'ready', key: row.id, result }); + }) + .catch((cause: unknown) => { + console.warn('Could not load the result:', cause instanceof Error ? cause.message : cause); + if (active) setState({ status: 'failed' }); + }); + return () => { + active = false; + }; + }, [userId, kind, id]); + + const result = state.status === 'ready' ? state.result : null; + const resultDebateId = result?.debateId ?? null; + + // The result's own debate's Library, for its citation chips. + useEffect(() => { + if (!userId || !resultDebateId) { + setLibrary([]); + return; + } + let active = true; + const load = () => + fetchSources(userId, resultDebateId) + .then((next) => { + if (active) setLibrary(next); + }) + .catch((cause: unknown) => console.warn('Could not load sources for citations:', cause instanceof Error ? cause.message : cause)); + void load(); + const unsubscribe = subscribeSourcesChanged(() => void load()); + return () => { + active = false; + unsubscribe(); + }; + }, [userId, resultDebateId]); + + // The website's undo covers the Current Debate's Auto-saved folder, also in one-off mode. + const currentId = current?.id ?? null; + const autoSavedRequest = useRef(0); + const loadAutoSaved = useCallback(() => { + const request = ++autoSavedRequest.current; + if (!userId || !currentId) { + setAutoSaved([]); + return; + } + fetchAutoSavedSourceIds(userId, currentId) + .then((ids) => { + if (live.current && autoSavedRequest.current === request) setAutoSaved(ids); + }) + .catch((cause: unknown) => console.warn('Could not count auto-saved sources:', cause instanceof Error ? cause.message : cause)); + }, [userId, currentId]); + useFocusEffect(loadAutoSaved); + + const remove = useCallback(async () => { + if (!result?.savedId || !userId) return; + const forUser = userId; + const savedId = result.savedId; + const confirmed = await confirmDestructive({ + title: kind === 'case' ? t('historyPage.deleteCaseTitle') : t('mobile.results.deleteAttackTitle'), + message: kind === 'case' ? t('historyPage.deleteCaseDesc') : t('mobile.results.deleteAttackDesc'), + confirm: t('common.delete'), + cancel: t('common.cancel'), + }); + if (!confirmed || !live.current || owner.current !== forUser) return; + setBusy(true); + try { + await deleteResult(forUser, kind, savedId); + forgetOpenResult(savedId); + if (state.status === 'ready') forgetOpenResult(state.key); + if (live.current) router.back(); + } catch (cause) { + console.warn('Could not delete the result:', cause instanceof Error ? cause.message : cause); + setNotice(t('mobile.results.deleteFailed')); + } finally { + if (live.current) setBusy(false); + } + }, [kind, result, router, setNotice, state, t, userId]); + + /** Saves an unsaved case outside any debate, as the website's Save button. */ + const save = useCallback(async () => { + if (state.status !== 'ready' || state.result.savedId || state.result.kind !== 'case' || !userId) return; + const forUser = userId; + const { key, result: open } = state; + const title = await promptText({ + title: t('generate.saveCase'), + message: t('generate.savePrompt'), + defaultValue: open.title, + confirm: t('common.save'), + cancel: t('common.cancel'), + }); + if (!title?.trim() || !live.current || owner.current !== forUser) return; + setBusy(true); + try { + const savedId = await saveCase(forUser, title.trim(), open.output as OpenResult<'case'>['output']); + updateOpenResult(key, { savedId, title: title.trim() }); + if (!live.current) return; + setState({ status: 'ready', key, result: { ...open, savedId, title: title.trim() } }); + setNotice(t('generate.saved')); + } catch (cause) { + console.warn('Could not save the case:', cause instanceof Error ? cause.message : cause); + setNotice(t('generate.saveFailedDesc')); + } finally { + if (live.current) setBusy(false); + } + }, [setNotice, state, t, userId]); + + const undoAutoSaved = useCallback(async () => { + if (!userId || autoSaved.length === 0) return; + const forUser = userId; + setBusy(true); + try { + const { succeeded, failed } = await removeSources(forUser, autoSaved); + if (!live.current || owner.current !== forUser) return; + if (failed === 0) setNotice(`${t('autoSavedSources.removedTitle')}. ${t('autoSavedSources.removedDesc', { count: succeeded })}`); + else if (succeeded === 0) setNotice(`${t('autoSavedSources.removeFailed')} ${t('autoSavedSources.removeFailedDesc', { succeeded, failed })}`); + else setNotice(`${t('autoSavedSources.removedPartialTitle')}. ${t('autoSavedSources.removedPartialDesc', { succeeded, failed })}`); + } finally { + if (live.current) setBusy(false); + loadAutoSaved(); + } + }, [autoSaved, loadAutoSaved, setNotice, t, userId]); + + return { state, library, autoSaved, notice, setNotice, busy, remove, save, undoAutoSaved }; +} diff --git a/apps/mobile/src/theme/colors.ts b/apps/mobile/src/theme/colors.ts index 883579aaa..4debe1277 100644 --- a/apps/mobile/src/theme/colors.ts +++ b/apps/mobile/src/theme/colors.ts @@ -25,6 +25,9 @@ export const palette = { tabBar: 'rgba(248,250,252,0.84)', destructive: '#dc2626', success: '#16a34a', + warning: '#d97706', + tileBorder: '#bae6fd', + tileText: '#0c4a6e', }, dark: { background: '#0f172a', @@ -47,6 +50,9 @@ export const palette = { tabBar: 'rgba(15,23,42,0.84)', destructive: '#f87171', success: '#4ade80', + warning: '#fbbf24', + tileBorder: '#1e4a6b', + tileText: '#e0f2fe', }, } as const; diff --git a/package-lock.json b/package-lock.json index 3f22ef75d..1cb4e067a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -125,14 +125,18 @@ "aes-js": "^3.1.2", "date-fns": "^3.6.0", "expo": "~57.0.27", + "expo-clipboard": "~57.0.2", "expo-constants": "~57.0.21", "expo-crypto": "~57.0.3", + "expo-file-system": "~57.0.7", "expo-font": "~57.0.4", "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.12", "expo-localization": "~57.0.2", + "expo-print": "~57.0.2", "expo-router": "~57.0.25", "expo-secure-store": "~57.0.4", + "expo-sharing": "~57.0.22", "expo-splash-screen": "~57.0.9", "expo-status-bar": "~57.0.1", "expo-system-ui": "~57.0.4", @@ -549,6 +553,17 @@ "react-native": "*" } }, + "apps/mobile/node_modules/expo-clipboard": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-57.0.2.tgz", + "integrity": "sha512-VB4Au8X/RbvJKUtJk+87vdtveWSNEa5RzY3ooeO6VNF7Rd49RbQXZOu3/TfEW9p7mU8TCfyTvjyCiAUwSoSJ4w==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "apps/mobile/node_modules/expo-constants": { "version": "57.0.21", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.21.tgz", @@ -684,6 +699,16 @@ "react-native": "*" } }, + "apps/mobile/node_modules/expo-print": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-print/-/expo-print-57.0.2.tgz", + "integrity": "sha512-jEbUqtH59xT42dCrmVVrg9Ct1ofD5GAHdQmUiu0EkHdzvqop7G6dt7rCXpq0iUYbu8ow6HH8yLRPbd3VMdTUug==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "apps/mobile/node_modules/expo-router": { "version": "57.0.25", "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.25.tgz", @@ -765,6 +790,22 @@ "expo": "*" } }, + "apps/mobile/node_modules/expo-sharing": { + "version": "57.0.22", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-57.0.22.tgz", + "integrity": "sha512-IWmiz0Dnjd7ifsZ2Gg5XODxb6DQoJ4/H6FofW9bTL9gDjfX2+va8SV1BYVkFvJ8vKQoKR4HgkMCKDCZlrrbKkg==", + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "^57.0.9", + "@expo/config-types": "^57.0.2", + "@expo/plist": "^0.8.1" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "apps/mobile/node_modules/expo-splash-screen": { "version": "57.0.9", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-57.0.9.tgz", diff --git a/src/locales/en.json b/src/locales/en.json index 890e92661..9b7fa90ca 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2490,6 +2490,41 @@ "debateMissing": "This debate no longer exists. Pick another debate and ask again.", "failed": "The coach could not answer. Try again." } + }, + "prepare": { + "saved": "Saved in this debate" + }, + "results": { + "generatedMeta": "{{side}}, generated {{when}}", + "evidenceCount_one": "{{count}} piece of evidence", + "evidenceCount_other": "{{count}} pieces of evidence", + "noEvidence": "No evidence yet", + "attackThisCase": "Attack this case", + "share": "Share", + "copyText": "Copy as text", + "loadFailed": "Could not load this result.", + "notFound": "This result is no longer saved.", + "deleteFailed": "Could not delete this result. Try again.", + "deleteAttackTitle": "Delete attack analysis?", + "deleteAttackDesc": "This removes the attack analysis from your history.", + "sourcesFailed_one": "{{count}} researched source could not be saved to your Library.", + "sourcesFailed_other": "{{count}} researched sources could not be saved to your Library.", + "exportFailed": "Could not export. Try again.", + "errors": { + "auth": "Your session has expired. Sign out and sign in again.", + "trialExpired": "Your free trial has ended. See your plan in You.", + "limitReached": "You have used today's AI requests. They refresh at midnight UTC. See your plan in You.", + "modelRestricted": "The model chosen for this tool needs Pro. See your plan in You.", + "rateLimit": "Too many requests right now. Wait a moment and try again.", + "network": "Couldn't reach the AI. Check your connection and retry.", + "timeout": "The AI took too long to respond. Please try again.", + "tooLarge": "This is too long to send. Shorten it and try again.", + "debateMissing": "This debate no longer exists. Pick another debate and try again.", + "failed": "Something went wrong. Try again." + } + }, + "attack": { + "caseRequired": "Paste or write the case you want attacked." } } } diff --git a/src/locales/es.json b/src/locales/es.json index dc4120f84..808f859cb 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2490,6 +2490,41 @@ "debateMissing": "Este debate ya no existe. Elige otro debate y vuelve a preguntar.", "failed": "El coach no pudo responder. Inténtalo de nuevo." } + }, + "prepare": { + "saved": "Guardado en este debate" + }, + "results": { + "generatedMeta": "{{side}}, generado {{when}}", + "evidenceCount_one": "{{count}} prueba", + "evidenceCount_other": "{{count}} pruebas", + "noEvidence": "Aún sin pruebas", + "attackThisCase": "Atacar este caso", + "share": "Compartir", + "copyText": "Copiar como texto", + "loadFailed": "No se pudo cargar este resultado.", + "notFound": "Este resultado ya no está guardado.", + "deleteFailed": "No se pudo eliminar este resultado. Inténtalo de nuevo.", + "deleteAttackTitle": "¿Eliminar el análisis de ataque?", + "deleteAttackDesc": "Esto eliminará el análisis de ataque de tu historial.", + "sourcesFailed_one": "{{count}} fuente investigada no se pudo guardar en tu Biblioteca.", + "sourcesFailed_other": "{{count}} fuentes investigadas no se pudieron guardar en tu Biblioteca.", + "exportFailed": "No se pudo exportar. Inténtalo de nuevo.", + "errors": { + "auth": "Tu sesión ha caducado. Cierra sesión y vuelve a entrar.", + "trialExpired": "Tu prueba gratuita ha terminado. Consulta tu plan en Tú.", + "limitReached": "Has usado las solicitudes de IA de hoy. Se renuevan a medianoche UTC. Consulta tu plan en Tú.", + "modelRestricted": "El modelo elegido para esta herramienta requiere Pro. Consulta tu plan en Tú.", + "rateLimit": "Demasiadas solicitudes ahora mismo. Espera un momento y vuelve a intentarlo.", + "network": "No se pudo conectar con la IA. Revisa tu conexión y vuelve a intentarlo.", + "timeout": "La IA tardó demasiado en responder. Inténtalo de nuevo.", + "tooLarge": "Es demasiado largo para enviarlo. Acórtalo y vuelve a intentarlo.", + "debateMissing": "Este debate ya no existe. Elige otro debate y vuelve a intentarlo.", + "failed": "Algo salió mal. Inténtalo de nuevo." + } + }, + "attack": { + "caseRequired": "Pega o escribe el caso que quieres atacar." } } } diff --git a/src/locales/hu.json b/src/locales/hu.json index f10f693ed..018701b40 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2490,6 +2490,41 @@ "debateMissing": "Ez a vita már nem létezik. Válassz másik vitát, és kérdezz újra.", "failed": "Az edző nem tudott válaszolni. Próbáld újra." } + }, + "prepare": { + "saved": "Ebben a vitában mentve" + }, + "results": { + "generatedMeta": "{{side}}, létrehozva {{when}}", + "evidenceCount_one": "{{count}} bizonyíték", + "evidenceCount_other": "{{count}} bizonyíték", + "noEvidence": "Még nincs bizonyíték", + "attackThisCase": "Támadd meg az esetet", + "share": "Megosztás", + "copyText": "Másolás szövegként", + "loadFailed": "Nem sikerült betölteni ezt az eredményt.", + "notFound": "Ez az eredmény már nincs elmentve.", + "deleteFailed": "Nem sikerült törölni az eredményt. Próbáld újra.", + "deleteAttackTitle": "Törlöd a támadáselemzést?", + "deleteAttackDesc": "Ezzel eltávolítod a támadáselemzést az előzményeidből.", + "sourcesFailed_one": "{{count}} kutatott forrást nem sikerült a Könyvtáradba menteni.", + "sourcesFailed_other": "{{count}} kutatott forrást nem sikerült a Könyvtáradba menteni.", + "exportFailed": "Nem sikerült exportálni. Próbáld újra.", + "errors": { + "auth": "Lejárt a munkameneted. Jelentkezz ki, majd be újra.", + "trialExpired": "Lejárt az ingyenes próbaidőszakod. Az előfizetésedet a Profil lapon látod.", + "limitReached": "Elhasználtad a mai MI-kéréseidet. UTC éjfélkor újraindulnak. Az előfizetésedet a Profil lapon látod.", + "modelRestricted": "Az ehhez az eszközhöz választott modellhez Pro előfizetés kell. Az előfizetésedet a Profil lapon látod.", + "rateLimit": "Most túl sok a kérés. Várj egy kicsit, és próbáld újra.", + "network": "Nem sikerült elérni az MI-t. Ellenőrizd a kapcsolatot, és próbáld újra.", + "timeout": "Az MI túl sokáig nem válaszolt. Próbáld újra.", + "tooLarge": "Ez túl hosszú az elküldéshez. Rövidítsd le, és próbáld újra.", + "debateMissing": "Ez a vita már nem létezik. Válassz másik vitát, és próbáld újra.", + "failed": "Valami hiba történt. Próbáld újra." + } + }, + "attack": { + "caseRequired": "Illeszd be vagy írd le a megtámadandó esetet." } } } From 1f3ca017546b10134ce84dda0e6c67c396ff9f23 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 13:13:52 +0000 Subject: [PATCH 4/7] Fit the pinned result buttons and keep them in the iPad column The pinned buttons share the width equally as in mock 03, with the mock's tight padding so Attack this case fits, and shorter Spanish and Hungarian labels. On iPad they stay in the document's centred column. Deleting a document that has nothing behind it returns to the Debate tab. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/components/buttons.tsx | 3 ++- apps/mobile/src/components/document.tsx | 9 ++++++--- apps/mobile/src/lib/use-result-document.ts | 6 +++++- src/locales/es.json | 2 +- src/locales/hu.json | 2 +- 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/components/buttons.tsx b/apps/mobile/src/components/buttons.tsx index 65e2e9025..1a69af448 100644 --- a/apps/mobile/src/components/buttons.tsx +++ b/apps/mobile/src/components/buttons.tsx @@ -172,7 +172,8 @@ const styles = StyleSheet.create({ }, primaryLabel: { fontSize: 17, flexShrink: 1 }, primaryShadow: { boxShadow: '0 6px 18px -8px rgba(2,132,199,0.6)' }, - secondary: { borderWidth: 1, paddingHorizontal: 14 }, + // The mock's outlined button has almost no side padding, so its label fits half the width. + secondary: { borderWidth: 1, paddingHorizontal: 6, gap: 6 }, secondaryShadow: { boxShadow: '0 6px 18px -8px rgba(15,23,42,0.35)' }, dimmed: { opacity: 0.6 }, text: { minHeight: 44, alignItems: 'center', justifyContent: 'center' }, diff --git a/apps/mobile/src/components/document.tsx b/apps/mobile/src/components/document.tsx index 59776dc73..e24ca92eb 100644 --- a/apps/mobile/src/components/document.tsx +++ b/apps/mobile/src/components/document.tsx @@ -45,8 +45,10 @@ export function DocumentScreen({ header, children, actions, notice, testID }: { ) : null} {actions ? ( - {/* The pinned buttons share the width equally, as in the mock. */} - {Children.map(unwrapFragment(actions), (action) => (action ? {action} : null))} + {/* The pinned buttons share the width equally, as in the mock, within the document's column on iPad. */} + + {Children.map(unwrapFragment(actions), (action) => (action ? {action} : null))} + ) : null}
@@ -307,7 +309,8 @@ export function Callout({ title, children }: { title: string; children: ReactNod const styles = StyleSheet.create({ root: { flex: 1 }, content: { paddingHorizontal: 18, paddingTop: 4, gap: 12, width: '100%', maxWidth: 680, alignSelf: 'center' }, - pinned: { position: 'absolute', left: 14, right: 14, flexDirection: 'row', gap: 10 }, + pinned: { position: 'absolute', left: 0, right: 0, alignItems: 'center' }, + pinnedRow: { flexDirection: 'row', gap: 10, width: '100%', maxWidth: 680, paddingHorizontal: 14 }, pinnedItem: { flex: 1 }, pressed: { opacity: 0.7 }, notice: { position: 'absolute', left: 24, right: 24, borderRadius: 14, paddingHorizontal: 16, paddingVertical: 11, alignSelf: 'center', maxWidth: 560 }, diff --git a/apps/mobile/src/lib/use-result-document.ts b/apps/mobile/src/lib/use-result-document.ts index 5708293c7..fa9e5640e 100644 --- a/apps/mobile/src/lib/use-result-document.ts +++ b/apps/mobile/src/lib/use-result-document.ts @@ -142,7 +142,11 @@ export function useResultDocument(kind: K, id: string) { await deleteResult(forUser, kind, savedId); forgetOpenResult(savedId); if (state.status === 'ready') forgetOpenResult(state.key); - if (live.current) router.back(); + // A document opened from a link has nothing behind it; go home instead. + if (live.current) { + if (router.canGoBack()) router.back(); + else router.replace('/'); + } } catch (cause) { console.warn('Could not delete the result:', cause instanceof Error ? cause.message : cause); setNotice(t('mobile.results.deleteFailed')); diff --git a/src/locales/es.json b/src/locales/es.json index 808f859cb..309581610 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2499,7 +2499,7 @@ "evidenceCount_one": "{{count}} prueba", "evidenceCount_other": "{{count}} pruebas", "noEvidence": "Aún sin pruebas", - "attackThisCase": "Atacar este caso", + "attackThisCase": "Atacar el caso", "share": "Compartir", "copyText": "Copiar como texto", "loadFailed": "No se pudo cargar este resultado.", diff --git a/src/locales/hu.json b/src/locales/hu.json index 018701b40..7abbb88ac 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2499,7 +2499,7 @@ "evidenceCount_one": "{{count}} bizonyíték", "evidenceCount_other": "{{count}} bizonyíték", "noEvidence": "Még nincs bizonyíték", - "attackThisCase": "Támadd meg az esetet", + "attackThisCase": "Megtámadom", "share": "Megosztás", "copyText": "Másolás szövegként", "loadFailed": "Nem sikerült betölteni ezt az eredményt.", From 3d689abf9b34ed61d3829d1a22023b07eaa74137 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 13:13:52 +0000 Subject: [PATCH 5/7] Screenshot Prepare, Generate, the case document, Export and Attack The screenshot script answers Generate and Attack with fixed outputs inside the page and fakes their saves, so it never calls a model or writes to staging. It renders the HTML handed to expo-print and keeps the copied text next to the screenshots. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 178 ++++++++++++++++++++++++++++- 1 file changed, 177 insertions(+), 1 deletion(-) diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 8a36f209e..9628be503 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -28,10 +28,17 @@ * fetch), answers the ai function with a fixed answer with citations, and * answers chat and message writes locally. The Library screens read the * account's real sources and write nothing. + * + * Generate and Attack screenshots never call a model and never save: the + * page answers the ai function with a fixed case or attack, holds it open + * 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. */ import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page } from '@playwright/test'; import { createReadStream, existsSync, statSync } from 'node:fs'; -import { mkdir } from 'node:fs/promises'; +import { mkdir, writeFile } from 'node:fs/promises'; import { createServer, type Server } from 'node:http'; import { extname, join, normalize } from 'node:path'; @@ -199,6 +206,173 @@ async function fakeAsk(page: Page, hold: boolean) { } } +// A made-up case in GenerateOutput's shape, after mock 03: three arguments for a Standard case. +const FAKE_CASE = { + caseStructure: { + thesis: 'City centres are shared public space, and private cars take the most of it while costing everyone else their health.', + framework: 'City centres are shared public space. The motion asks who gets to use that space, and at what cost to everyone else\'s health.', + keyDefinitions: ['City centre: the area inside a city\'s inner ring road', 'Private cars: privately owned passenger vehicles, with exemptions for disabled drivers'], + burdenOfProof: 'We must show that removing private cars improves health and access for most people who use the centre.', + }, + contentions: [ + { + id: '1', + claim: 'Clean air is a public health duty', + reasoning: 'Traffic is the largest source of roadside pollution, and the people who breathe it most are pedestrians, cyclists and residents, not drivers.', + evidence: 'Low-traffic zones cut roadside NO₂ within their first year, according to the Clean Streets Review.', + needsSource: false, + impact: 'Fewer asthma admissions among children who live and study in the centre.', + }, + { + id: '2', + claim: 'Streets back for people', + reasoning: 'Parked and moving cars take most of a centre\'s street space while carrying a minority of the people in it.', + evidence: 'The Street Space Audit found cars use most of the road area; the Footfall Study found shops gain visits when streets close to traffic.', + needsSource: false, + impact: 'Wider pavements, more seating and safer crossings for everyone.', + }, + { + id: '3', + claim: 'Transit works when it isn\'t stuck in traffic', + reasoning: 'Buses lose most of their delay to congestion caused by private cars.', + evidence: 'Bus journeys became quicker and more reliable after the centre closed to through traffic, per the Clean Streets Review.', + needsSource: false, + impact: 'Faster, more reliable buses make the ban fair to people who cannot walk or cycle.', + }, + ], + predictedOpponentCase: { + likelyArguments: ['Shift workers and carers need cars at hours when transit is thin', 'Businesses lose customers who drive in'], + strategicWeaknesses: ['Depends on exemptions working in practice'], + keyClashes: ['Access for the few against health for the many', 'Shops and footfall'], + }, + counters: [ + { targetArgument: 'Shift workers need cars', counterpoint: 'Exemptions and night buses cover them.', rebuttal: 'Every real ban exempts carers and shift workers, and transit follows demand.' }, + ], + metadata: { + side: 'pro', + format: 'custom', + generatedAt: '2026-10-09T16:43:00.000Z', + model: 'test-model', + provider: 'openrouter', + durationMs: 41200, + sourceUsage: [ + { key: 'https://example.org/clean-streets', title: 'Clean Streets Review', origin: 'https://example.org/clean-streets', importance: 3, strength: 3, probability: 0.9, judgment: 'used' }, + { key: 'https://example.org/street-space', title: 'Street Space Audit', origin: 'https://example.org/street-space', importance: 2, strength: 2, probability: 0.8, judgment: 'used' }, + { key: 'https://example.org/footfall', title: 'Footfall Study', origin: 'https://example.org/footfall', importance: 2, strength: 2, probability: 0.7, judgment: 'used' }, + ], + }, +}; +const FAKE_SOURCES = [ + { title: 'Clean Streets Review', url: 'https://example.org/clean-streets', content: 'Made up for screenshots.', qualityLabel: 'strong', qualityScore: 82 }, + { title: 'Footfall Study', url: 'https://example.org/footfall', content: 'Made up for screenshots.', qualityLabel: 'moderate', qualityScore: 64 }, +]; +// A made-up attack in AttackOutput's shape. +const FAKE_ATTACK = { + overallAssessment: { + score: 64, + summary: 'A clear case with strong health evidence, but it leans on exemptions it never defines.', + strengthAreas: ['Concrete health impact', 'Good use of the street space argument'], + }, + weaknesses: [ + { id: 'w1', severity: 'critical', type: 'evidential', description: 'Exemptions are assumed, not shown', whyWeak: 'The case says carers and shift workers are exempt but never says how.', howOpponentAttacks: 'Who checks the exemptions, and what happens to the nurse at 5 a.m.?', improvedVersion: 'Name a real scheme and say how its exemptions work.', affectedQuote: 'Every real ban exempts carers' }, + { id: 'w2', severity: 'major', type: 'logical', description: 'Footfall gains may not transfer', whyWeak: 'One study of one city does not prove shops gain everywhere.', howOpponentAttacks: 'Your study is one city with a metro.', improvedVersion: 'Compare two centres of different sizes.' }, + { id: 'w3', severity: 'minor', type: 'structural', description: 'Impact of the third argument is thin', whyWeak: 'It restates the claim.', howOpponentAttacks: 'So what if buses are faster?', improvedVersion: 'Tie faster buses to who can reach jobs.' }, + ], + fallaciesDetected: [{ name: 'Hasty generalisation', location: 'Argument 2', explanation: 'One city stands in for all cities.' }], + crossExQuestions: [{ question: 'How many people would need an exemption?', purpose: 'Test the size of the carve-out', expectedTrap: 'A large number undercuts the health benefit' }], + weighingAnalysis: { currentWeighing: 'Health outweighs convenience.', missingComparisons: ['Cost to low-income drivers'], improvementSuggestions: ['Weigh by number of people affected'] }, +}; +const FAKE_ROW = (table: string) => ({ + id: table === 'sources' ? '00000000-0000-4000-8000-0000000050c0' : '00000000-0000-4000-8000-00000000ca5e', + title: 'fake', + content: '', + type: 'url', + origin: null, + metadata: null, + 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) { + 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: [] } }); + return route.abort(); + }); + for (const table of ['cases', 'attacks', 'sources', 'source_folders']) { + await page.route(`${REST}/${table}*`, (route) => + route.request().method() === 'POST' ? route.fulfill({ status: 201, json: FAKE_ROW(table) }) : route.continue(), + ); + } +} + +/** Prepare, Generate, the case document, Export and Attack, all from fixed outputs (see the header). */ +async function shootResultScreens(context: BrowserContext, prefix: string, language: string, problems: string[]) { + const open = async (hold: boolean) => { + const page = await context.newPage(); + watchErrors(page, problems, `${prefix} results`); + page.on('dialog', (dialog) => void dialog.dismiss()); + await pinSettings(page, language); + await fakeResults(page, hold); + return page; + }; + + const waiting = await open(true); + await waiting.goto(`${ORIGIN}/prepare`); + await waiting.getByTestId('prepare-tool-generate').waitFor({ timeout: 30_000 }); + await settle(waiting); + await shoot(waiting, `${prefix}-prepare`); + await waiting.getByTestId('prepare-tool-generate').click(); + await waiting.getByTestId('generate-motion').waitFor({ timeout: 30_000 }); + await waiting.waitForFunction(() => (document.querySelector('[data-testid="generate-motion"]') as HTMLTextAreaElement | null)?.value.trim()); + await shoot(waiting, `${prefix}-generate-form`); + await waiting.getByRole('button', { name: /^(Generate Debate Case|Vitaeset Generálása|Generar Caso de Debate)$/ }).click(); + await waiting.getByTestId('tool-progress').waitFor({ timeout: 15_000 }); + await shoot(waiting, `${prefix}-generating`); + await waiting.close(); + + const page = await open(false); + await page.goto(`${ORIGIN}/generate?from=prepare`); + await page.getByTestId('generate-motion').waitFor({ timeout: 30_000 }); + await page.waitForFunction(() => (document.querySelector('[data-testid="generate-motion"]') as HTMLTextAreaElement | null)?.value.trim()); + await page.getByRole('button', { name: /^(Generate Debate Case|Vitaeset Generálása|Generar Caso de Debate)$/ }).click(); + await page.getByTestId('case-document').waitFor({ timeout: 30_000 }); + await page.waitForTimeout(400); + await shoot(page, `${prefix}-case`); + // Close the open argument, so every argument shows collapsed. + await page.getByTestId('document-item').first().click(); + await shoot(page, `${prefix}-case-collapsed`); + await page.getByTestId('document-item').first().click(); + + await page.getByTestId('export-button').click(); + await page.getByTestId('export-menu').waitFor(); + await shoot(page, `${prefix}-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('Export did not hand over the PDF HTML or the copied text'); + await writeFile(join(OUT, `${prefix}-case-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}-case-pdf-html.png`), fullPage: true }); + await pdf.close(); + console.log(`${prefix}: PDF file name ${exported.fileName}, copied ${exported.copied.length} characters`); + + await page.getByTestId('attack-this-case').click(); + await page.getByTestId('attack-case-text').waitFor({ timeout: 15_000 }); + await page.waitForFunction(() => (document.querySelector('[data-testid="attack-case-text"]') as HTMLTextAreaElement | null)?.value.startsWith('THESIS:')); + await shoot(page, `${prefix}-attack-form`); + await page.getByRole('button', { name: /^(Find Weaknesses|Gyengeségek Keresése|Encontrar Debilidades)$/ }).click(); + await page.getByTestId('attack-document').waitFor({ timeout: 30_000 }); + await page.waitForTimeout(400); + await shoot(page, `${prefix}-attack-result`); + await page.close(); +} + async function shootLibraryScreens(page: Page, prefix: string) { await page.goto(`${ORIGIN}/library`); await page.getByTestId('library-add').waitFor({ timeout: 30_000 }); @@ -402,6 +576,8 @@ async function main() { shots += 1; await shootAskScreens(signedIn, prefix, language, problems); shots += 3; + await shootResultScreens(signedIn, prefix, language, problems); + shots += 9; await signedIn.close(); } } From 7b939dfee3ca41f79c9418736cedc730209b89f6 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 14:05:05 +0000 Subject: [PATCH 6/7] 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 --- apps/mobile/AGENTS.md | 2 +- .../src/app/(tabs)/(prepare)/prepare.tsx | 14 +++-- apps/mobile/src/app/ask.tsx | 29 ++++++--- apps/mobile/src/app/attack/[id].tsx | 6 +- apps/mobile/src/app/attack/index.tsx | 17 +++--- apps/mobile/src/app/case/[id].tsx | 6 +- apps/mobile/src/app/generate.tsx | 19 +++--- .../mobile/src/components/attack-document.tsx | 3 +- apps/mobile/src/lib/ai-request.ts | 14 +++++ apps/mobile/src/lib/ask.ts | 17 ++++-- apps/mobile/src/lib/auth.tsx | 15 +++-- apps/mobile/src/lib/open-results.ts | 13 ++-- apps/mobile/src/lib/results.test.ts | 26 ++++++++ apps/mobile/src/lib/results.ts | 57 +++++++++++++++--- apps/mobile/src/lib/session-memory.ts | 30 ++++++++++ apps/mobile/src/lib/sources.ts | 39 +++++++++++- apps/mobile/src/lib/use-result-document.ts | 46 ++++++++++---- packages/core/src/ai/source-assessments.ts | 60 +++++++++++++++++++ src/lib/ai/client.ts | 26 +------- src/lib/stores/session-store.ts | 32 +--------- 20 files changed, 347 insertions(+), 124 deletions(-) create mode 100644 apps/mobile/src/lib/results.test.ts create mode 100644 apps/mobile/src/lib/session-memory.ts create mode 100644 packages/core/src/ai/source-assessments.ts diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index c4bf61388..0d9febb40 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -21,7 +21,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - `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`) 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. +- 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. 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. diff --git a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx index 69ddf8c99..de269dc39 100644 --- a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx +++ b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx @@ -27,23 +27,25 @@ export default function PrepareScreen() { const userId = session?.user.id ?? null; const { current, oneOff } = useDebates(); const debateId = current?.id ?? null; - const [items, setItems] = useState(null); - const [failed, setFailed] = useState(false); + // Each load belongs to the debate it was read for, so another debate's results never show, or open, under this one. + const [loaded, setLoaded] = useState<{ debateId: string; items: DebateItem[] | null; failed: boolean } | null>(null); + const items = loaded?.debateId === debateId ? loaded.items : null; + const failed = loaded?.debateId === debateId && loaded.failed; const openTool = (tool: PrepareTool) => tool === 'generate' ? router.push({ pathname: '/generate', params: { from: 'prepare' } }) : router.push({ pathname: '/coming/[tool]', params: { tool } }); const load = useCallback(async (isCurrent: () => boolean) => { if (!userId || !debateId) return; + const forDebate = debateId; try { - const next = await fetchPrepareWork(userId, debateId, SAVED_LIMIT); + const next = await fetchPrepareWork(userId, forDebate, SAVED_LIMIT); if (!isCurrent()) return; - setItems(next); - setFailed(false); + setLoaded({ debateId: forDebate, items: next, failed: false }); } catch (cause) { if (!isCurrent()) return; console.warn('Could not load saved results:', cause instanceof Error ? cause.message : cause); - setFailed(true); + setLoaded({ debateId: forDebate, items: null, failed: true }); } }, [userId, debateId]); diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx index 1a650c8b0..ff3094736 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -12,13 +12,14 @@ import { Chip } from '@/components/chip'; import { Icon } from '@/components/icon'; import type { IconName } from '@/components/icon-paths'; import { Text } from '@/components/text'; -import { AIError, isAbortError, type AIErrorKind } from '@/lib/ai-request'; +import { AIError, isAbortError, type AIErrorKind, type ResponseExtras } from '@/lib/ai-request'; import { askOnce, canStream, isFinalAskError, isMessageWithinBudget, streamAsk, type AskOptions } from '@/lib/ask'; import { buildDebateContext } from '@/lib/context-request'; import { fetchFeatureConfig, type FeatureConfig } from '@/lib/feature-config'; import { useAuth } from '@/lib/auth'; import { ChatSaveError, fetchChat, saveChat, type ChatMessage, type ChatRecord } from '@/lib/chats'; import { useDebates } from '@/lib/debate-context'; +import { keepSourceAssessments } from '@/lib/results'; import { useSettings } from '@/lib/settings'; import { fetchSources, type Source } from '@/lib/sources'; import { radius } from '@/theme/colors'; @@ -125,10 +126,9 @@ export default function AskScreen() { // The debate this conversation reads from: a loaded chat keeps its own. const chatDebateId = record.chatId ? record.debateId : activeDebateId; useEffect(() => { - if (!chatDebateId || !userId) { - setLibrary([]); - return; - } + // The previous debate's sources never stand in for this one's while they load. + setLibrary([]); + if (!chatDebateId || !userId) return; let active = true; fetchSources(userId, chatDebateId) .then((next) => { @@ -200,6 +200,10 @@ export default function AskScreen() { }; let result: AskOutput; + const whole: { extras?: ResponseExtras } = {}; + const keepExtras = (extras: ResponseExtras) => { + whole.extras = extras; + }; if (canStream(requestOptions)) { let received = ''; let pending = ''; @@ -231,16 +235,27 @@ export default function AskScreen() { } else { // Nothing arrived: ask once more without streaming, as the website does. setStreaming(null); - result = await askOnce(askRequest, controller.signal); + result = await askOnce(askRequest, controller.signal, keepExtras); } } finally { if (flush) clearTimeout(flush); } } else { - result = await askOnce(askRequest, controller.signal); + result = await askOnce(askRequest, controller.signal, keepExtras); } if (!isCurrent()) return; + // A whole answer ranks the Library sources it used and says when the question was shortened, as on the website. + if (whole.extras) { + const { truncated, assessments } = whole.extras; + const assessed = await keepSourceAssessments(userId, debateId, assessments); + if (!isCurrent()) return; + const notices = [ + ...(assessed ? [] : [`${t('aiClient.sourceSaveFailed')}. ${t('aiClient.sourceAssessmentSaveFailed')}`]), + ...(truncated ? [`${t('aiClient.inputShortened')}. ${t('aiClient.inputShortenedDesc')}`] : []), + ]; + if (notices.length > 0) setNotice(notices.join(' ')); + } const output = { ...result, model: result.model || askSettings.model, diff --git a/apps/mobile/src/app/attack/[id].tsx b/apps/mobile/src/app/attack/[id].tsx index 41ca5cf50..69817e897 100644 --- a/apps/mobile/src/app/attack/[id].tsx +++ b/apps/mobile/src/app/attack/[id].tsx @@ -14,7 +14,7 @@ import { copyText, sharePdf, shareText } from '@/lib/export'; import { attackExportFileName, attackExportHtml } from '@/lib/export-html'; import { attackToText, discussAttackMessage } from '@/lib/result-text'; import { isCompleteAttack } from '@/lib/results'; -import { useResultDocument } from '@/lib/use-result-document'; +import { arrivalNoticeText, useResultDocument } from '@/lib/use-result-document'; /** * An attack analysis in the same document style as a case. Its pinned next @@ -29,8 +29,8 @@ export default function AttackResultScreen() { const { setNotice } = doc; useEffect(() => { - if (arrival === 'recordFailed') setNotice(t('debate.recordFailed')); - if (arrival === 'sourcesFailed') setNotice(t('mobile.results.sourcesFailed', { count: Number(failed) || 1 })); + const text = arrivalNoticeText(t, arrival, failed); + if (text) setNotice(text); }, [arrival, failed, setNotice, t]); const result = doc.state.status === 'ready' ? doc.state.result : null; diff --git a/apps/mobile/src/app/attack/index.tsx b/apps/mobile/src/app/attack/index.tsx index 194a01c7f..9c3dbd198 100644 --- a/apps/mobile/src/app/attack/index.tsx +++ b/apps/mobile/src/app/attack/index.tsx @@ -19,7 +19,8 @@ import { useDebates } from '@/lib/debate-context'; import { fetchFeatureConfig } from '@/lib/feature-config'; import { keepOpenResult, openResult } from '@/lib/open-results'; import { caseToText } from '@/lib/result-text'; -import { attackCase, autosaveSources, fetchResult, isCompleteCase, recordResult } from '@/lib/results'; +import { arrivalNotices, attackCase, autosaveSources, fetchResult, isCompleteCase, keepSourceAssessments, recordResult } from '@/lib/results'; +import { sessionSlot } from '@/lib/session-memory'; import { useSettings } from '@/lib/settings'; import { useTheme } from '@/theme/use-theme'; @@ -31,7 +32,7 @@ const DEFAULT_FORM: AttackInput = { detectFallacies: true, analyzeWeighing: true, }; -let lastForm: AttackInput = DEFAULT_FORM; +const lastForm = sessionSlot(() => DEFAULT_FORM); const PROGRESS = ['attackForm.loadingMsg1', 'attackForm.loadingMsg2', 'attackForm.loadingMsg3', 'attackForm.loadingMsg4', 'attackForm.loadingMsg5']; @@ -48,7 +49,7 @@ export default function AttackScreen() { const { activeDebateId, refresh } = useDebates(); const { outputLanguage } = useSettings(); const { colors } = useTheme(); - const [form, setFormState] = useState(lastForm); + const [form, setFormState] = useState(lastForm.get); const [textError, setTextError] = useState(false); const [phase, setPhase] = useState<'form' | 'attacking'>('form'); const [error, setError] = useState(null); @@ -59,7 +60,7 @@ export default function AttackScreen() { const setForm = useCallback((patch: Partial) => setFormState((previous) => { const next = { ...previous, ...patch }; - lastForm = next; + lastForm.set(next); return next; }), []); @@ -117,9 +118,11 @@ export default function AttackScreen() { debateId ? buildDebateContext(forUser, debateId, 'attack', 'both') : Promise.resolve(undefined), ]); if (!isCurrent()) return; - const { result, discoveredSources } = await attackCase(input, { config, language: outputLanguage, debateContext, signal: controller.signal }); + const { result, discoveredSources, truncated, assessments } = await attackCase(input, { config, language: outputLanguage, debateContext, signal: controller.signal }); if (!isCurrent()) return; - const sources = await autosaveSources(forUser, discoveredSources, 'attack', debateId); + const assessed = await keepSourceAssessments(forUser, debateId, assessments); + if (!isCurrent()) return; + const sources = await autosaveSources(forUser, discoveredSources, 'attack', debateId, controller.signal); if (!isCurrent()) return; const title = result.overallAssessment?.summary?.slice(0, 80) || input.caseText.slice(0, 80); let savedId: string | null = null; @@ -134,7 +137,7 @@ export default function AttackScreen() { } if (!isCurrent()) return; const key = keepOpenResult({ kind: 'attack', savedId, title, side: 'neutral', debateId, createdAt: Date.now(), input, output: result, sources: discoveredSources }); - const notice = recordFailed ? 'recordFailed' : sources.failed > 0 ? 'sourcesFailed' : undefined; + const notice = arrivalNotices({ recordFailed, sourcesFailed: sources.failed, assessed, truncated }); router.replace({ pathname: '/attack/[id]', params: { diff --git a/apps/mobile/src/app/case/[id].tsx b/apps/mobile/src/app/case/[id].tsx index eddf39b75..699221708 100644 --- a/apps/mobile/src/app/case/[id].tsx +++ b/apps/mobile/src/app/case/[id].tsx @@ -15,7 +15,7 @@ import { copyText, sharePdf, shareText } from '@/lib/export'; import { caseExportFileName, caseExportHtml } from '@/lib/export-html'; import { caseToText, discussCaseMessage } from '@/lib/result-text'; import { isCompleteCase } from '@/lib/results'; -import { useResultDocument } from '@/lib/use-result-document'; +import { arrivalNoticeText, useResultDocument } from '@/lib/use-result-document'; /** A generated case read as a document, as in mock 03. */ export default function CaseScreen() { @@ -28,8 +28,8 @@ export default function CaseScreen() { // Problems from saving a fresh result are told once, on arrival. useEffect(() => { - if (arrival === 'recordFailed') setNotice(t('debate.recordFailed')); - if (arrival === 'sourcesFailed') setNotice(t('mobile.results.sourcesFailed', { count: Number(failed) || 1 })); + const text = arrivalNoticeText(t, arrival, failed); + if (text) setNotice(text); }, [arrival, failed, setNotice, t]); const result = doc.state.status === 'ready' ? doc.state.result : null; diff --git a/apps/mobile/src/app/generate.tsx b/apps/mobile/src/app/generate.tsx index 8f8fd538d..fa7f069ff 100644 --- a/apps/mobile/src/app/generate.tsx +++ b/apps/mobile/src/app/generate.tsx @@ -21,7 +21,8 @@ import { fetchFeatureConfig } from '@/lib/feature-config'; import { decideMotionTarget, shouldPrefillMotion } from '@/lib/motion'; import { chooseMotionTarget } from '@/lib/motion-gate'; import { keepOpenResult } from '@/lib/open-results'; -import { autosaveSources, generateCase, recordResult } from '@/lib/results'; +import { arrivalNotices, autosaveSources, generateCase, keepSourceAssessments, recordResult } from '@/lib/results'; +import { sessionSlot } from '@/lib/session-memory'; import { useSettings } from '@/lib/settings'; import { useTheme } from '@/theme/use-theme'; @@ -35,7 +36,7 @@ const DEFAULT_FORM: GenerateInput = { audience: 'intermediate', tone: 'formal', }; -let lastForm: GenerateInput = DEFAULT_FORM; +const lastForm = sessionSlot(() => DEFAULT_FORM); const PROGRESS = ['generateForm.loadingMsg1', 'generateForm.loadingMsg2', 'generateForm.loadingMsg3', 'generateForm.loadingMsg4', 'generateForm.loadingMsg5']; @@ -49,7 +50,7 @@ export default function GenerateScreen() { const { debates, current, oneOff, create, switchTo, refresh } = useDebates(); const { outputLanguage } = useSettings(); const { colors } = useTheme(); - const [form, setFormState] = useState(lastForm); + const [form, setFormState] = useState(lastForm.get); const [motionError, setMotionError] = useState(false); const [phase, setPhase] = useState<'form' | 'generating'>('form'); const [error, setError] = useState(null); @@ -60,7 +61,7 @@ export default function GenerateScreen() { const setForm = useCallback((patch: Partial) => setFormState((previous) => { const next = { ...previous, ...patch }; - lastForm = next; + lastForm.set(next); return next; }), []); @@ -79,7 +80,7 @@ export default function GenerateScreen() { // The motion follows the Current Debate unless the student typed their own. useEffect(() => { if (oneOff || !current) return; - if (shouldPrefillMotion(lastForm.motion, current.motion, debates.map((debate) => debate.motion))) setForm({ motion: current.motion }); + if (shouldPrefillMotion(lastForm.get().motion, current.motion, debates.map((debate) => debate.motion))) setForm({ motion: current.motion }); }, [current, debates, oneOff, setForm]); const start = async (input: GenerateInput, debateId: string | null, forUser: string) => { @@ -96,9 +97,11 @@ export default function GenerateScreen() { debateId ? buildDebateContext(forUser, debateId, 'generate', input.side) : Promise.resolve(undefined), ]); if (!isCurrent()) return; - const { result, discoveredSources } = await generateCase(input, { config, language: outputLanguage, debateContext, signal: controller.signal }); + const { result, discoveredSources, truncated, assessments } = await generateCase(input, { config, language: outputLanguage, debateContext, signal: controller.signal }); if (!isCurrent()) return; - const sources = await autosaveSources(forUser, discoveredSources, 'generate', debateId); + const assessed = await keepSourceAssessments(forUser, debateId, assessments); + if (!isCurrent()) return; + const sources = await autosaveSources(forUser, discoveredSources, 'generate', debateId, controller.signal); if (!isCurrent()) return; let savedId: string | null = null; let recordFailed = false; @@ -122,7 +125,7 @@ export default function GenerateScreen() { output: result, sources: discoveredSources, }); - const notice = recordFailed ? 'recordFailed' : sources.failed > 0 ? 'sourcesFailed' : undefined; + const notice = arrivalNotices({ recordFailed, sourcesFailed: sources.failed, assessed, truncated }); router.replace({ pathname: '/case/[id]', params: { id: key, from: from ?? 'prepare', ...(notice ? { notice } : {}), ...(sources.failed > 0 ? { failed: String(sources.failed) } : {}) }, diff --git a/apps/mobile/src/components/attack-document.tsx b/apps/mobile/src/components/attack-document.tsx index 83b234f40..2062386d9 100644 --- a/apps/mobile/src/components/attack-document.tsx +++ b/apps/mobile/src/components/attack-document.tsx @@ -30,7 +30,8 @@ export function AttackDocument({ output, sources }: { output: AttackOutput; sour const severityLabel = t(`attackResults.${severity}`); const typeLabel = t(`attackResults.types.${weakness.type}`, { defaultValue: weakness.type }); return { - key: weakness.id || `${index}`, + // Model ids can repeat, so the expansion state is keyed by position. + key: `${index}`, title: weakness.description, summary: `${severityLabel} · ${typeLabel}`, body: ( diff --git a/apps/mobile/src/lib/ai-request.ts b/apps/mobile/src/lib/ai-request.ts index 5af13d4b0..f6af55746 100644 --- a/apps/mobile/src/lib/ai-request.ts +++ b/apps/mobile/src/lib/ai-request.ts @@ -1,3 +1,4 @@ +import { parseSourceAssessments, type SourceAssessmentUpdate } from '@debatelab/core/ai/source-assessments'; import { fetch } from 'expo/fetch'; import { supabase } from './supabase'; @@ -125,3 +126,16 @@ export async function functionOutput(response: FunctionResponse): Promise<{ outp if (!data?.output) throw new AIError('failed', 'The answer was missing'); return { output: data.output, data }; } + +/** What a whole answer says besides its output, which the website acts on for every feature (callSupabaseAI). */ +export interface ResponseExtras { + /** The server shortened the input to fit; the website tells the student. */ + truncated: boolean; + /** Rankings of the Library sources the answer used, saved onto those sources. */ + assessments: SourceAssessmentUpdate[]; +} + +export function responseExtras(data: Record): ResponseExtras { + const meta = data.meta && typeof data.meta === 'object' ? (data.meta as { truncated?: unknown }) : null; + return { truncated: meta?.truncated === true, assessments: parseSourceAssessments(data.sourceAssessments) }; +} diff --git a/apps/mobile/src/lib/ask.ts b/apps/mobile/src/lib/ask.ts index 93738033c..d912a4f68 100644 --- a/apps/mobile/src/lib/ask.ts +++ b/apps/mobile/src/lib/ask.ts @@ -2,7 +2,7 @@ import { AskOutputSchema, type AskOutput } from '@debatelab/core/ai/schemas/ask' import { consumeSSEChunk, type ParsedSSEEvent } from '@debatelab/core/ai/sse'; import type { DebateContextRequest } from '@debatelab/core/contract/debate-context'; -import { abortError, AIError, errorFrom, functionOutput, postFunction, PROVIDER, utf8Length } from './ai-request'; +import { abortError, AIError, errorFrom, functionOutput, postFunction, PROVIDER, responseExtras, utf8Length, type ResponseExtras } from './ai-request'; import type { FeatureConfig } from './feature-config'; /** @@ -160,8 +160,12 @@ export function streamAsk(request: AskRequest, handlers: { onDelta: (text: strin }); } -/** Asks the ai Edge Function for a whole answer, with the options the stream cannot serve. */ -export function askOnce(request: AskRequest, signal?: AbortSignal): Promise { +/** + * Asks the ai Edge Function for a whole answer, with the options the stream + * cannot serve. onExtras receives what the answer says besides its output, + * which the website acts on for whole answers only. + */ +export function askOnce(request: AskRequest, signal?: AbortSignal, onExtras?: (extras: ResponseExtras) => void): Promise { const body = { feature: 'ask', input: requestInput(request), @@ -173,5 +177,10 @@ export function askOnce(request: AskRequest, signal?: AbortSignal): Promise parseOutput((await functionOutput(response)).output)); + return postFunction('ai', body, signal, async (response) => { + const { output, data } = await functionOutput(response); + const result = parseOutput(output); + onExtras?.(responseExtras(data)); + return result; + }); } diff --git a/apps/mobile/src/lib/auth.tsx b/apps/mobile/src/lib/auth.tsx index ec0083080..85ab720d0 100644 --- a/apps/mobile/src/lib/auth.tsx +++ b/apps/mobile/src/lib/auth.tsx @@ -1,7 +1,8 @@ import type { Session } from '@supabase/supabase-js'; -import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { getInstallId, sessionStorage } from './device-storage'; +import { setSessionOwner } from './session-memory'; import { supabase } from './supabase'; import { sessionStorageKey } from './supabase-config'; @@ -76,7 +77,13 @@ async function signOutError(scope: 'global' | 'local'): Promise { } export function AuthProvider({ children }: { children: ReactNode }) { - const [session, setSession] = useState(null); + const [session, setSessionState] = useState(null); + // The session memory is cleared before an account change renders, so no + // screen ever shows the previous account's forms or results. + const setSession = useCallback((next: Session | null) => { + setSessionOwner(next?.user.id ?? null); + setSessionState(next); + }, []); const [loading, setLoading] = useState(true); const [settled, setSettled] = useState(false); // Counts auth changes, so a storage read that lands after a newer change is dropped. @@ -107,7 +114,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { setSettled(true); }); return () => data.subscription.unsubscribe(); - }, []); + }, [setSession]); const value = useMemo(() => ({ session, @@ -155,7 +162,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { setSession(null); setLoading(false); }, - }), [session, loading, settled]); + }), [session, loading, settled, setSession]); return {children}; } diff --git a/apps/mobile/src/lib/open-results.ts b/apps/mobile/src/lib/open-results.ts index bba9d1432..a9a85321f 100644 --- a/apps/mobile/src/lib/open-results.ts +++ b/apps/mobile/src/lib/open-results.ts @@ -1,11 +1,13 @@ import type { DiscoveredSource } from '@debatelab/core/ai/discovered-sources'; import type { OutputFor, ResultKind } from './results'; +import { sessionSlot } from './session-memory'; /** * Results produced in this session, so their document opens at once and still * opens when nothing was saved: one-off work, or a save that failed. The - * researched sources live only here, as they do on the website's page. + * researched sources live only here, as they do on the website's page. They + * belong to the signed-in account and are forgotten when it changes. */ export interface OpenResult { kind: K; @@ -20,26 +22,27 @@ export interface OpenResult { sources: DiscoveredSource[]; } -const results = new Map(); +const store = sessionSlot(() => new Map()); let localId = 0; /** Keeps a result under its saved id, or a local key when it is not saved. Returns the key to open it with. */ export function keepOpenResult(result: OpenResult): string { const key = result.savedId ?? `local-${Date.now()}-${(localId += 1)}`; - results.set(key, result as OpenResult); + store.get().set(key, result as OpenResult); return key; } export function openResult(key: string, kind: K): OpenResult | undefined { - const result = results.get(key); + const result = store.get().get(key); return result?.kind === kind ? (result as OpenResult) : undefined; } export function updateOpenResult(key: string, patch: Partial>) { + const results = store.get(); const result = results.get(key); if (result) results.set(key, { ...result, ...patch }); } export function forgetOpenResult(key: string) { - results.delete(key); + store.get().delete(key); } diff --git a/apps/mobile/src/lib/results.test.ts b/apps/mobile/src/lib/results.test.ts new file mode 100644 index 000000000..ea0e54a89 --- /dev/null +++ b/apps/mobile/src/lib/results.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest'; + +const addSource = vi.hoisted(() => vi.fn()); + +vi.mock('./supabase', () => ({ supabase: {} })); +vi.mock('expo/fetch', () => ({ fetch: vi.fn() })); +vi.mock('./sources', () => ({ addSource, applySourceAssessments: vi.fn(), fetchSourceIdsInFolder: vi.fn(), removeSource: vi.fn() })); + +import { autosaveSources } from './results'; + +describe('autosaveSources', () => { + it('writes no further source once Generate or Attack is cancelled during a write', async () => { + const controller = new AbortController(); + let finishFirst = () => {}; + addSource.mockImplementationOnce(() => new Promise((resolve) => (finishFirst = resolve))); + addSource.mockResolvedValue(undefined); + const sources = ['a', 'b', 'c'].map((name) => ({ title: name, url: `https://example.com/${name}` })); + + const saving = autosaveSources('user-1', sources, 'generate', 'debate-1', controller.signal); + controller.abort(); + finishFirst(); + + await expect(saving).resolves.toEqual({ saved: 1, failed: 0 }); + expect(addSource).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/lib/results.ts b/apps/mobile/src/lib/results.ts index ab9cac7c1..bc7ea9f9d 100644 --- a/apps/mobile/src/lib/results.ts +++ b/apps/mobile/src/lib/results.ts @@ -9,11 +9,12 @@ import { import { expectedContentionCount, transformGenerateResponse } from '@debatelab/core/ai/generate-output'; import { AttackOutputSchema, type AttackInput, type AttackOutput } from '@debatelab/core/ai/schemas/attack'; import { GenerateOutputSchema, type GenerateInput, type GenerateOutput } from '@debatelab/core/ai/schemas/generate'; +import type { SourceAssessmentUpdate } from '@debatelab/core/ai/source-assessments'; import type { DebateContextRequest } from '@debatelab/core/contract/debate-context'; -import { AIError, functionOutput, postFunction, PROVIDER } from './ai-request'; +import { AIError, functionOutput, postFunction, PROVIDER, responseExtras, type ResponseExtras } from './ai-request'; import type { FeatureConfig } from './feature-config'; -import { addSource, fetchSourceIdsInFolder, removeSource } from './sources'; +import { addSource, applySourceAssessments, fetchSourceIdsInFolder, removeSource } from './sources'; import { supabase } from './supabase'; /** @@ -66,7 +67,7 @@ function requestBody(feature: 'generate' | 'attack', input: Record> { +export function generateCase(input: GenerateInput, options: RequestOptions): Promise & ResponseExtras> { return postFunction('ai', requestBody('generate', input, options), options.signal, async (response) => { const { output, data } = await functionOutput(response); const parsed = GenerateOutputSchema.safeParse(transformGenerateResponse(output as Record)); @@ -77,16 +78,16 @@ export function generateCase(input: GenerateInput, options: RequestOptions): Pro } result.metadata.generatedAt = new Date().toISOString(); result.metadata.motion = input.motion; - return { result, discoveredSources: parseDiscoveredSources(data.discoveredSources) }; + return { result, discoveredSources: parseDiscoveredSources(data.discoveredSources), ...responseExtras(data) }; }); } -export function attackCase(input: AttackInput, options: RequestOptions): Promise> { +export function attackCase(input: AttackInput, options: RequestOptions): Promise & ResponseExtras> { return postFunction('ai', requestBody('attack', input, options), options.signal, async (response) => { const { output, data } = await functionOutput(response); const parsed = AttackOutputSchema.safeParse(output); if (!parsed.success) throw new AIError('failed', 'AI response did not match the expected attack schema.'); - return { result: parsed.data, discoveredSources: parseDiscoveredSources(data.discoveredSources) }; + return { result: parsed.data, discoveredSources: parseDiscoveredSources(data.discoveredSources), ...responseExtras(data) }; }); } @@ -94,12 +95,20 @@ export function attackCase(input: AttackInput, options: RequestOptions): Promise * Keeps the researched sources worth keeping in the debate's Auto-saved * folder, one at a time, as the website does. One-off research is not kept. * A source that fails to save is skipped; the count of failures is returned. + * Once the request is cancelled or replaced, no further source is written. */ -export async function autosaveSources(userId: string, sources: DiscoveredSource[], capturedVia: 'generate' | 'attack', debateId: string | null): Promise<{ saved: number; failed: number }> { +export async function autosaveSources( + userId: string, + sources: DiscoveredSource[], + capturedVia: 'generate' | 'attack', + debateId: string | null, + signal?: AbortSignal, +): Promise<{ saved: number; failed: number }> { if (!debateId) return { saved: 0, failed: 0 }; let saved = 0; let failed = 0; for (const source of retainedDiscoveredSources(sources)) { + if (signal?.aborted) break; try { await addSource(userId, { ...autoSavedSourceRow(source, capturedVia), debateId }); saved += 1; @@ -111,13 +120,43 @@ export async function autosaveSources(userId: string, sources: DiscoveredSource[ return { saved, failed }; } +/** + * Saves the rankings of the Library sources a result used, into the debate + * the request was sent for. One-off work has no Library to rank. Returns + * false when the rankings could not be saved; the result itself still stands. + */ +export async function keepSourceAssessments(userId: string, debateId: string | null, assessments: SourceAssessmentUpdate[]): Promise { + if (!debateId || assessments.length === 0) return true; + try { + await applySourceAssessments(userId, debateId, assessments); + return true; + } catch (cause) { + console.warn('Could not save the source rankings:', cause instanceof Error ? cause.message : cause); + return false; + } +} + +/** Problems from making a result, told once when its document opens. */ +export type ArrivalNotice = 'recordFailed' | 'sourcesFailed' | 'assessmentsFailed' | 'shortened'; + +/** The arrival notices as one route parameter, or undefined when there is nothing to tell. */ +export function arrivalNotices(outcome: { recordFailed: boolean; sourcesFailed: number; assessed: boolean; truncated: boolean }): string | undefined { + const notices: ArrivalNotice[] = []; + if (outcome.recordFailed) notices.push('recordFailed'); + else if (outcome.sourcesFailed > 0) notices.push('sourcesFailed'); + if (!outcome.assessed) notices.push('assessmentsFailed'); + if (outcome.truncated) notices.push('shortened'); + return notices.length > 0 ? notices.join(',') : undefined; +} + /** The website's rule for its undo: every source in the debate's Auto-saved folder. */ export function fetchAutoSavedSourceIds(userId: string, debateId: string): Promise { return fetchSourceIdsInFolder(userId, debateId, AUTO_SAVED_FOLDER); } -export async function removeSources(userId: string, ids: string[]): Promise<{ succeeded: number; failed: number }> { - const results = await Promise.allSettled(ids.map((id) => removeSource(userId, id))); +/** Removes the debate's auto-saved sources; a source moved to another debate since is left alone. */ +export async function removeSources(userId: string, debateId: string, ids: string[]): Promise<{ succeeded: number; failed: number }> { + const results = await Promise.allSettled(ids.map((id) => removeSource(userId, id, debateId))); const succeeded = results.filter((result) => result.status === 'fulfilled').length; return { succeeded, failed: results.length - succeeded }; } diff --git a/apps/mobile/src/lib/session-memory.ts b/apps/mobile/src/lib/session-memory.ts new file mode 100644 index 000000000..21bb3d813 --- /dev/null +++ b/apps/mobile/src/lib/session-memory.ts @@ -0,0 +1,30 @@ +/** + * What the app remembers for the session outside React: the last Generate and + * Attack forms and the results made in this session. It belongs to the + * signed-in account and is forgotten whenever the account changes, including + * signing out, so the next account never sees it. + */ + +const resets = new Set<() => void>(); +let owner: string | null = null; + +/** A value that starts from `initial()` again for every account. */ +export function sessionSlot(initial: () => T): { get: () => T; set: (value: T) => void } { + let value = initial(); + resets.add(() => { + value = initial(); + }); + return { + get: () => value, + set: (next) => { + value = next; + }, + }; +} + +/** Called by the auth provider before it adopts a session, so no screen renders with the previous account's memory. */ +export function setSessionOwner(userId: string | null) { + if (userId === owner) return; + owner = userId; + resets.forEach((reset) => reset()); +} diff --git a/apps/mobile/src/lib/sources.ts b/apps/mobile/src/lib/sources.ts index 7e57b1942..4ef831f28 100644 --- a/apps/mobile/src/lib/sources.ts +++ b/apps/mobile/src/lib/sources.ts @@ -1,4 +1,6 @@ +import type { SourceSemanticMetadata } from '@debatelab/core/ai/discovered-sources'; import { SourceImportOutputSchema, type SourceImportOutput } from '@debatelab/core/ai/schemas/source-import'; +import { assessedSemantic, isAssessedSource, type SourceAssessmentUpdate } from '@debatelab/core/ai/source-assessments'; import { getEdgeFunctionErrorMessage } from '@debatelab/core/supabase/edge-function-error'; import { supabase } from './supabase'; @@ -137,12 +139,45 @@ export async function fetchSourceIdsInFolder(userId: string, debateId: string, f return ((data ?? []) as { id: string }[]).map((row) => row.id); } -export async function removeSource(userId: string, id: string): Promise { - const { error } = await supabase.from('sources').delete().eq('user_id', userId).eq('id', id); +/** Removes a source; with a debate, only while the source is still in that debate. */ +export async function removeSource(userId: string, id: string, debateId?: string): Promise { + const query = supabase.from('sources').delete().eq('user_id', userId).eq('id', id); + const { error } = await (debateId ? query.eq('debate_id', debateId) : query); if (error) throw new Error(error.message); notifySourcesChanged(); } +/** + * Saves the server's rankings onto the debate's Library sources a result + * used, as the website's applySourceAssessments does. The debate is the one + * the request was sent for, read when the student acted; a URL saved in + * several debates is ranked only in that one. + */ +export async function applySourceAssessments(userId: string, debateId: string, assessments: SourceAssessmentUpdate[]): Promise { + if (assessments.length === 0) return; + const { data, error } = await supabase + .from('sources') + .select('id,origin,metadata') + .eq('user_id', userId) + .eq('debate_id', debateId); + if (error) throw new Error(error.message); + type Row = { id: string; origin: string | null; metadata: { url?: string; semantic?: SourceSemanticMetadata } | null }; + const candidates = (data ?? []) as Row[]; + const updates = new Map(); + for (const assessment of assessments.slice(0, 10)) { + const source = candidates.find((candidate) => isAssessedSource(candidate, assessment)); + if (!source) continue; + updates.set(source.id, { ...source.metadata, semantic: assessedSemantic(source.metadata?.semantic, assessment) }); + } + if (updates.size === 0) return; + const results = await Promise.all( + Array.from(updates, ([id, metadata]) => supabase.from('sources').update({ metadata }).eq('user_id', userId).eq('debate_id', debateId).eq('id', id)), + ); + notifySourcesChanged(); + const failed = results.find((result) => result.error); + if (failed?.error) throw new Error(failed.error.message); +} + /** The website rejects a URL that is already in the debate's Library. */ export function isDuplicateOrigin(sources: Source[], origin: string): boolean { const needle = origin.trim().toLowerCase(); diff --git a/apps/mobile/src/lib/use-result-document.ts b/apps/mobile/src/lib/use-result-document.ts index fa9e5640e..1d48539c5 100644 --- a/apps/mobile/src/lib/use-result-document.ts +++ b/apps/mobile/src/lib/use-result-document.ts @@ -1,12 +1,13 @@ import { useFocusEffect, useRouter } from 'expo-router'; import { useCallback, useEffect, useRef, useState } from 'react'; +import type { TFunction } from 'i18next'; import { useTranslation } from 'react-i18next'; import { useAuth } from './auth'; import { confirmDestructive, promptText } from './confirm'; import { useDebates } from './debate-context'; import { forgetOpenResult, keepOpenResult, openResult, updateOpenResult, type OpenResult } from './open-results'; -import { deleteResult, fetchAutoSavedSourceIds, fetchResult, removeSources, saveCase, type ResultKind } from './results'; +import { deleteResult, fetchAutoSavedSourceIds, fetchResult, removeSources, saveCase, type ArrivalNotice, type ResultKind } from './results'; import { fetchSources, subscribeSourcesChanged, type Source } from './sources'; export type DocumentState = @@ -17,12 +18,26 @@ export type DocumentState = const NOTICE_MS = 3500; +/** The arrival notices a fresh result carries in its route, as one line; empty when there are none. */ +export function arrivalNoticeText(t: TFunction, notice: string | undefined, failed: string | undefined): string { + const text: Record string> = { + recordFailed: () => t('debate.recordFailed'), + sourcesFailed: () => t('mobile.results.sourcesFailed', { count: Number(failed) || 1 }), + assessmentsFailed: () => `${t('aiClient.sourceSaveFailed')}. ${t('aiClient.sourceAssessmentSaveFailed')}`, + shortened: () => `${t('aiClient.inputShortened')}. ${t('aiClient.inputShortenedDesc')}`, + }; + return (notice ?? '') + .split(',') + .flatMap((code) => (code in text ? [text[code as ArrivalNotice]()] : [])) + .join(' '); +} + /** * A result's document: the result itself (from this session or the saved * row), the debate's Library for its citation chips, the Current Debate's * auto-saved sources for the undo, and the menu's actions. Every action reads - * the account when the student acts, and nothing lands after the screen is - * left or the account changes. + * the account and the debate when the student acts, and nothing lands after + * the screen is left or the account changes. */ export function useResultDocument(kind: K, id: string) { const { t } = useTranslation(); @@ -35,7 +50,8 @@ export function useResultDocument(kind: K, id: string) { return cached ? { status: 'ready', key: id, result: cached } : { status: 'loading' }; }); const [library, setLibrary] = useState([]); - const [autoSaved, setAutoSaved] = useState([]); + // The ids belong to the debate they were read for; another debate never sees or deletes them. + const [autoSavedFor, setAutoSavedFor] = useState<{ debateId: string; ids: string[] } | null>(null); const [notice, setNoticeState] = useState(''); const [busy, setBusy] = useState(false); const live = useRef(true); @@ -111,16 +127,20 @@ export function useResultDocument(kind: K, id: string) { // The website's undo covers the Current Debate's Auto-saved folder, also in one-off mode. const currentId = current?.id ?? null; + const currentRef = useRef(currentId); + currentRef.current = currentId; + const autoSaved = autoSavedFor && autoSavedFor.debateId === currentId ? autoSavedFor.ids : []; const autoSavedRequest = useRef(0); const loadAutoSaved = useCallback(() => { const request = ++autoSavedRequest.current; if (!userId || !currentId) { - setAutoSaved([]); + setAutoSavedFor(null); return; } - fetchAutoSavedSourceIds(userId, currentId) + const debateId = currentId; + fetchAutoSavedSourceIds(userId, debateId) .then((ids) => { - if (live.current && autoSavedRequest.current === request) setAutoSaved(ids); + if (live.current && autoSavedRequest.current === request) setAutoSavedFor({ debateId, ids }); }) .catch((cause: unknown) => console.warn('Could not count auto-saved sources:', cause instanceof Error ? cause.message : cause)); }, [userId, currentId]); @@ -184,11 +204,17 @@ export function useResultDocument(kind: K, id: string) { }, [setNotice, state, t, userId]); const undoAutoSaved = useCallback(async () => { - if (!userId || autoSaved.length === 0) return; + if (!userId || !autoSavedFor || autoSavedFor.ids.length === 0) return; + // The ids read for one debate never delete anything after a switch to another. + if (autoSavedFor.debateId !== currentRef.current) { + loadAutoSaved(); + return; + } const forUser = userId; + const { debateId, ids } = autoSavedFor; setBusy(true); try { - const { succeeded, failed } = await removeSources(forUser, autoSaved); + const { succeeded, failed } = await removeSources(forUser, debateId, ids); if (!live.current || owner.current !== forUser) return; if (failed === 0) setNotice(`${t('autoSavedSources.removedTitle')}. ${t('autoSavedSources.removedDesc', { count: succeeded })}`); else if (succeeded === 0) setNotice(`${t('autoSavedSources.removeFailed')} ${t('autoSavedSources.removeFailedDesc', { succeeded, failed })}`); @@ -197,7 +223,7 @@ export function useResultDocument(kind: K, id: string) { if (live.current) setBusy(false); loadAutoSaved(); } - }, [autoSaved, loadAutoSaved, setNotice, t, userId]); + }, [autoSavedFor, loadAutoSaved, setNotice, t, userId]); return { state, library, autoSaved, notice, setNotice, busy, remove, save, undoAutoSaved }; } diff --git a/packages/core/src/ai/source-assessments.ts b/packages/core/src/ai/source-assessments.ts new file mode 100644 index 000000000..3bc78e964 --- /dev/null +++ b/packages/core/src/ai/source-assessments.ts @@ -0,0 +1,60 @@ +import type { SourceSemanticMetadata } from './discovered-sources'; + +/** The ai function's ranking of a Library source a result used, from `data.sourceAssessments`. */ +export interface SourceAssessmentUpdate { + key: string; + id?: string; + relevance: number; + importance: 0 | 1 | 2 | 3; + strength: 0 | 1 | 2 | 3; + evaluatedAt: string; + retained: boolean; + usedInCase?: boolean; +} + +/** At most ten well-formed assessments; anything malformed is dropped. */ +export function parseSourceAssessments(value: unknown): SourceAssessmentUpdate[] { + if (!Array.isArray(value)) return []; + return value.slice(0, 10).flatMap((item): SourceAssessmentUpdate[] => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return []; + const candidate = item as Record; + const key = typeof candidate.key === 'string' ? candidate.key.trim() : ''; + const { relevance, importance, strength, evaluatedAt } = candidate; + if (!key || typeof relevance !== 'number' || !Number.isFinite(relevance) || relevance < 0 || relevance > 1 || + typeof importance !== 'number' || !Number.isInteger(importance) || importance < 0 || importance > 3 || + typeof strength !== 'number' || !Number.isInteger(strength) || strength < 0 || strength > 3 || + typeof evaluatedAt !== 'string' || !Number.isFinite(Date.parse(evaluatedAt)) || typeof candidate.retained !== 'boolean') return []; + return [{ + key, + ...(typeof candidate.id === 'string' && candidate.id.trim() ? { id: candidate.id.trim() } : {}), + relevance, + importance: importance as 0 | 1 | 2 | 3, + strength: strength as 0 | 1 | 2 | 3, + evaluatedAt, + retained: candidate.retained, + ...(typeof candidate.usedInCase === 'boolean' ? { usedInCase: candidate.usedInCase } : {}), + }]; + }); +} + +/** Whether an assessment is about this source: by id, or by the key the server gave it. */ +export function isAssessedSource(source: { id: string; origin?: string | null; metadata?: { url?: string } | null }, assessment: SourceAssessmentUpdate): boolean { + return source.id === assessment.id || source.id === assessment.key || source.origin === assessment.key || source.metadata?.url === assessment.key; +} + +/** The source's semantic metadata after one more use, ranked by this assessment. */ +export function assessedSemantic(prior: SourceSemanticMetadata | undefined, assessment: SourceAssessmentUpdate): SourceSemanticMetadata { + const usedInCase = assessment.usedInCase; + return { + relevance: assessment.relevance, + importance: assessment.importance, + strength: assessment.strength, + evaluatedAt: assessment.evaluatedAt, + useCount: (prior?.useCount ?? 0) + 1, + caseUseCount: (prior?.caseUseCount ?? 0) + (usedInCase === true ? 1 : 0), + lastUsedAt: assessment.evaluatedAt, + ...(usedInCase !== undefined + ? { lastUsedInCase: usedInCase } + : prior?.lastUsedInCase !== undefined ? { lastUsedInCase: prior.lastUsedInCase } : {}), + }; +} diff --git a/src/lib/ai/client.ts b/src/lib/ai/client.ts index 5956f5d32..be681c7d3 100644 --- a/src/lib/ai/client.ts +++ b/src/lib/ai/client.ts @@ -19,8 +19,8 @@ import { activeDebateId, useDebateStore } from '../stores/debate-store'; import { parseDebateContextUsage, useDebateContextStore } from '../stores/debate-context-store'; import { currentDebateContextPlan } from '../debate/context-state'; import { requestSide, type DebateContextRequest } from '../../../supabase/functions/_shared/debate-context'; -import { type SourceAssessmentUpdate } from '../stores/session-store'; import { cleanGeneratedOutput, expectedContentionCount, transformGenerateResponse } from '@debatelab/core/ai/generate-output'; +import { parseSourceAssessments } from '@debatelab/core/ai/source-assessments'; import { autoSavedSourceRow, parseDiscoveredSources, @@ -161,30 +161,6 @@ function waitForAbortable(operation: () => PromiseLike, signal: AbortSigna }); } -function parseSourceAssessments(value: unknown): SourceAssessmentUpdate[] { - if (!Array.isArray(value)) return []; - return value.slice(0, 10).flatMap((item): SourceAssessmentUpdate[] => { - if (!item || typeof item !== 'object' || Array.isArray(item)) return []; - const candidate = item as Record; - const key = typeof candidate.key === 'string' ? candidate.key.trim() : ''; - const { relevance, importance, strength, evaluatedAt } = candidate; - if (!key || typeof relevance !== 'number' || !Number.isFinite(relevance) || relevance < 0 || relevance > 1 || - typeof importance !== 'number' || !Number.isInteger(importance) || importance < 0 || importance > 3 || - typeof strength !== 'number' || !Number.isInteger(strength) || strength < 0 || strength > 3 || - typeof evaluatedAt !== 'string' || !Number.isFinite(Date.parse(evaluatedAt)) || typeof candidate.retained !== 'boolean') return []; - return [{ - key, - ...(typeof candidate.id === 'string' && candidate.id.trim() ? { id: candidate.id.trim() } : {}), - relevance, - importance: importance as 0 | 1 | 2 | 3, - strength: strength as 0 | 1 | 2 | 3, - evaluatedAt, - retained: candidate.retained, - ...(typeof candidate.usedInCase === 'boolean' ? { usedInCase: candidate.usedInCase } : {}), - }]; - }); -} - // Enable debug logging in development const DEBUG = import.meta.env.DEV; diff --git a/src/lib/stores/session-store.ts b/src/lib/stores/session-store.ts index 7918535ea..506658bab 100644 --- a/src/lib/stores/session-store.ts +++ b/src/lib/stores/session-store.ts @@ -9,6 +9,7 @@ import type { AskOutput } from '../ai/schemas/ask'; import { supabase } from '@/lib/supabase/client'; import i18n from '@/i18n'; import { activeDebateId, useDebateStore, type DebateSide } from './debate-store'; +import { assessedSemantic, isAssessedSource, type SourceAssessmentUpdate } from '@debatelab/core/ai/source-assessments'; interface Message { id: string; @@ -49,17 +50,6 @@ export interface SourceProvenance { semantic?: SourceSemanticMetadata; } -export interface SourceAssessmentUpdate { - key: string; - id?: string; - relevance: number; - importance: 0 | 1 | 2 | 3; - strength: 0 | 1 | 2 | 3; - evaluatedAt: string; - retained: boolean; - usedInCase?: boolean; -} - export interface Source { id: string; title: string; @@ -627,27 +617,11 @@ export const useSessionStore = create()( const candidates = previous.filter((source) => source.debateId === debateId); const updates = new Map(); for (const assessment of assessments.slice(0, 10)) { - const source = candidates.find((candidate) => candidate.id === assessment.id || candidate.id === assessment.key || candidate.origin === assessment.key || candidate.metadata?.url === assessment.key); + const source = candidates.find((candidate) => isAssessedSource(candidate, assessment)); if (!source) continue; - const prior = source.metadata?.semantic; - const usedInCase = assessment.usedInCase; updates.set(source.id, { ...source, - metadata: { - ...source.metadata, - semantic: { - relevance: assessment.relevance, - importance: assessment.importance, - strength: assessment.strength, - evaluatedAt: assessment.evaluatedAt, - useCount: (prior?.useCount ?? 0) + 1, - caseUseCount: (prior?.caseUseCount ?? 0) + (usedInCase === true ? 1 : 0), - lastUsedAt: assessment.evaluatedAt, - ...(usedInCase !== undefined - ? { lastUsedInCase: usedInCase } - : prior?.lastUsedInCase !== undefined ? { lastUsedInCase: prior.lastUsedInCase } : {}), - }, - }, + metadata: { ...source.metadata, semantic: assessedSemantic(source.metadata?.semantic, assessment) }, }); } if (updates.size === 0) return; From adb53aee42a26a3a2e5ce85c7e8fb23fde171bb3 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 14:19:38 +0000 Subject: [PATCH 7/7] Drop a stale Prepare retry and stop source rankings on cancel Prepare's Retry skipped the ownership check, so a late retry for one debate could replace another debate's loaded results and leave a spinner. Every load now takes a sequence number and only the newest may write. Generate, Attack and Ask's whole-answer path pass their request signal to the source rankings, which stop before using the read and before writing once the student cancels. Co-Authored-By: Claude Opus 5.5 --- .../src/app/(tabs)/(prepare)/prepare.tsx | 15 ++++++----- apps/mobile/src/app/ask.tsx | 2 +- apps/mobile/src/app/attack/index.tsx | 2 +- apps/mobile/src/app/generate.tsx | 2 +- apps/mobile/src/lib/results.ts | 5 ++-- apps/mobile/src/lib/sources.test.ts | 27 +++++++++++++++++++ apps/mobile/src/lib/sources.ts | 8 +++--- 7 files changed, 47 insertions(+), 14 deletions(-) create mode 100644 apps/mobile/src/lib/sources.test.ts diff --git a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx index de269dc39..b3da5f601 100644 --- a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx +++ b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx @@ -1,5 +1,5 @@ import { useFocusEffect, useRouter } from 'expo-router'; -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; @@ -35,9 +35,13 @@ export default function PrepareScreen() { const openTool = (tool: PrepareTool) => tool === 'generate' ? router.push({ pathname: '/generate', params: { from: 'prepare' } }) : router.push({ pathname: '/coming/[tool]', params: { tool } }); - const load = useCallback(async (isCurrent: () => boolean) => { + // Every load, from focus or Retry, takes a number; only the newest may write, so a late Retry for another debate never replaces this one's. + const loads = useRef(0); + const load = useCallback(async () => { if (!userId || !debateId) return; const forDebate = debateId; + const request = ++loads.current; + const isCurrent = () => loads.current === request; try { const next = await fetchPrepareWork(userId, forDebate, SAVED_LIMIT); if (!isCurrent()) return; @@ -52,10 +56,9 @@ export default function PrepareScreen() { // Reload on focus, so a case generated a moment ago, or on the website, shows up. useFocusEffect( useCallback(() => { - let active = true; - void load(() => active); + void load(); return () => { - active = false; + loads.current += 1; }; }, [load]), ); @@ -97,7 +100,7 @@ export default function PrepareScreen() { {t('mobile.debate.loadFailed')} - void load(() => true)} /> + void load()} /> ) : items === null ? ( diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx index ff3094736..0d432ced5 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -248,7 +248,7 @@ export default function AskScreen() { // A whole answer ranks the Library sources it used and says when the question was shortened, as on the website. if (whole.extras) { const { truncated, assessments } = whole.extras; - const assessed = await keepSourceAssessments(userId, debateId, assessments); + const assessed = await keepSourceAssessments(userId, debateId, assessments, controller.signal); if (!isCurrent()) return; const notices = [ ...(assessed ? [] : [`${t('aiClient.sourceSaveFailed')}. ${t('aiClient.sourceAssessmentSaveFailed')}`]), diff --git a/apps/mobile/src/app/attack/index.tsx b/apps/mobile/src/app/attack/index.tsx index 9c3dbd198..696f6ee0b 100644 --- a/apps/mobile/src/app/attack/index.tsx +++ b/apps/mobile/src/app/attack/index.tsx @@ -120,7 +120,7 @@ export default function AttackScreen() { if (!isCurrent()) return; const { result, discoveredSources, truncated, assessments } = await attackCase(input, { config, language: outputLanguage, debateContext, signal: controller.signal }); if (!isCurrent()) return; - const assessed = await keepSourceAssessments(forUser, debateId, assessments); + const assessed = await keepSourceAssessments(forUser, debateId, assessments, controller.signal); if (!isCurrent()) return; const sources = await autosaveSources(forUser, discoveredSources, 'attack', debateId, controller.signal); if (!isCurrent()) return; diff --git a/apps/mobile/src/app/generate.tsx b/apps/mobile/src/app/generate.tsx index fa7f069ff..ca7192b1b 100644 --- a/apps/mobile/src/app/generate.tsx +++ b/apps/mobile/src/app/generate.tsx @@ -99,7 +99,7 @@ export default function GenerateScreen() { if (!isCurrent()) return; const { result, discoveredSources, truncated, assessments } = await generateCase(input, { config, language: outputLanguage, debateContext, signal: controller.signal }); if (!isCurrent()) return; - const assessed = await keepSourceAssessments(forUser, debateId, assessments); + const assessed = await keepSourceAssessments(forUser, debateId, assessments, controller.signal); if (!isCurrent()) return; const sources = await autosaveSources(forUser, discoveredSources, 'generate', debateId, controller.signal); if (!isCurrent()) return; diff --git a/apps/mobile/src/lib/results.ts b/apps/mobile/src/lib/results.ts index bc7ea9f9d..9d496c52a 100644 --- a/apps/mobile/src/lib/results.ts +++ b/apps/mobile/src/lib/results.ts @@ -124,11 +124,12 @@ export async function autosaveSources( * Saves the rankings of the Library sources a result used, into the debate * the request was sent for. One-off work has no Library to rank. Returns * false when the rankings could not be saved; the result itself still stands. + * Once the request is cancelled or replaced, no ranking is written. */ -export async function keepSourceAssessments(userId: string, debateId: string | null, assessments: SourceAssessmentUpdate[]): Promise { +export async function keepSourceAssessments(userId: string, debateId: string | null, assessments: SourceAssessmentUpdate[], signal?: AbortSignal): Promise { if (!debateId || assessments.length === 0) return true; try { - await applySourceAssessments(userId, debateId, assessments); + await applySourceAssessments(userId, debateId, assessments, signal); return true; } catch (cause) { console.warn('Could not save the source rankings:', cause instanceof Error ? cause.message : cause); diff --git a/apps/mobile/src/lib/sources.test.ts b/apps/mobile/src/lib/sources.test.ts new file mode 100644 index 000000000..c07b1ae2a --- /dev/null +++ b/apps/mobile/src/lib/sources.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest'; + +const supabase = vi.hoisted(() => ({ from: vi.fn() })); + +vi.mock('./supabase', () => ({ supabase })); + +import { applySourceAssessments } from './sources'; + +describe('applySourceAssessments', () => { + it('writes no ranking once Generate, Attack or Ask is cancelled while the sources are read', async () => { + const controller = new AbortController(); + let finishRead = (_rows: unknown) => {}; + const read = new Promise((resolve) => (finishRead = resolve)); + const chain = { eq: () => chain, then: read.then.bind(read) }; + const written = { eq: () => written, then: (done: (value: unknown) => unknown) => done({ error: null }) }; + const update = vi.fn(() => written); + supabase.from.mockReturnValue({ select: () => chain, update }); + const assessment = { key: 'https://example.com/a', relevance: 0.8, importance: 2 as const, strength: 2 as const, evaluatedAt: '2026-10-09T00:00:00Z', retained: true }; + + const applying = applySourceAssessments('user-1', 'debate-1', [assessment], controller.signal); + controller.abort(); + finishRead({ data: [{ id: 'source-a', origin: 'https://example.com/a', metadata: null }], error: null }); + + await expect(applying).resolves.toBeUndefined(); + expect(update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/lib/sources.ts b/apps/mobile/src/lib/sources.ts index 4ef831f28..9a6283402 100644 --- a/apps/mobile/src/lib/sources.ts +++ b/apps/mobile/src/lib/sources.ts @@ -151,15 +151,17 @@ export async function removeSource(userId: string, id: string, debateId?: string * Saves the server's rankings onto the debate's Library sources a result * used, as the website's applySourceAssessments does. The debate is the one * the request was sent for, read when the student acted; a URL saved in - * several debates is ranked only in that one. + * several debates is ranked only in that one. Once the request is cancelled + * or replaced, nothing is written. */ -export async function applySourceAssessments(userId: string, debateId: string, assessments: SourceAssessmentUpdate[]): Promise { - if (assessments.length === 0) return; +export async function applySourceAssessments(userId: string, debateId: string, assessments: SourceAssessmentUpdate[], signal?: AbortSignal): Promise { + if (assessments.length === 0 || signal?.aborted) return; const { data, error } = await supabase .from('sources') .select('id,origin,metadata') .eq('user_id', userId) .eq('debate_id', debateId); + if (signal?.aborted) return; if (error) throw new Error(error.message); type Row = { id: string; origin: string | null; metadata: { url?: string; semantic?: SourceSemanticMetadata } | null }; const candidates = (data ?? []) as Row[];