From 9de0058711f8358587db73c046744f9bdf12229c Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 07:49:24 +0000 Subject: [PATCH 1/5] Move the SSE parser into the shared core package The app needs the website's parser for ai-stream responses. streaming.ts imports it back and re-exports it, so its behaviour and tests are unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/ai/sse.ts | 40 +++++++++++++++++++++++++++++++++++ src/lib/ai/streaming.ts | 42 +++---------------------------------- 2 files changed, 43 insertions(+), 39 deletions(-) create mode 100644 packages/core/src/ai/sse.ts diff --git a/packages/core/src/ai/sse.ts b/packages/core/src/ai/sse.ts new file mode 100644 index 000000000..a6aa7ffec --- /dev/null +++ b/packages/core/src/ai/sse.ts @@ -0,0 +1,40 @@ +// Server-sent events parsing for the ai-stream Edge Function, shared by the website and the app. + +export type ParsedSSEEvent = { + event: string; + data: unknown; +}; + +function parseEventBlock(block: string): ParsedSSEEvent | null { + const lines = block.split(/\r?\n/); + let event = 'message'; + const dataLines: string[] = []; + + for (const line of lines) { + if (line.startsWith('event:')) { + event = line.slice('event:'.length).trim(); + } else if (line.startsWith('data:')) { + dataLines.push(line.slice('data:'.length).trimStart()); + } + } + + if (dataLines.length === 0) return null; + + const rawData = dataLines.join('\n'); + try { + return { event, data: JSON.parse(rawData) }; + } catch { + return { event, data: rawData }; + } +} + +export function consumeSSEChunk(buffer: string, chunk: string): { events: ParsedSSEEvent[]; buffer: string } { + const normalized = `${buffer}${chunk}`.replace(/\r\n/g, '\n'); + const parts = normalized.split('\n\n'); + const rest = parts.pop() ?? ''; + const events = parts + .map(parseEventBlock) + .filter((event): event is ParsedSSEEvent => event !== null); + + return { events, buffer: rest }; +} diff --git a/src/lib/ai/streaming.ts b/src/lib/ai/streaming.ts index eaf144713..3401d63cc 100644 --- a/src/lib/ai/streaming.ts +++ b/src/lib/ai/streaming.ts @@ -4,6 +4,7 @@ import { AIAuthError, AIResponseError, REQUEST_TIMEOUT_MS, TimeoutError, boundAs import { parseDebateContextUsage, useDebateContextStore } from '@/lib/stores/debate-context-store'; import type { ProviderConfig } from './providers/types'; import { AskOutputSchema, type AskOutput } from './schemas/ask'; +import { consumeSSEChunk, type ParsedSSEEvent } from '@debatelab/core/ai/sse'; export interface AskStreamOptions { mode?: string; @@ -15,17 +16,14 @@ export interface AskStreamOptions { debateId?: string | null; } +export { consumeSSEChunk, type ParsedSSEEvent }; + export interface AskStreamHandlers { onDelta: (text: string) => void; onFinal?: (output: AskOutput) => void; signal?: AbortSignal; } -export type ParsedSSEEvent = { - event: string; - data: unknown; -}; - export function isAskStreamingSupported(config: ProviderConfig, options: AskStreamOptions = {}) { const advancedStructuredMode = Boolean( (options.mode ?? 'general') !== 'general' || @@ -38,40 +36,6 @@ export function isAskStreamingSupported(config: ProviderConfig, options: AskStre return !advancedStructuredMode && config.provider === 'openrouter'; } -function parseEventBlock(block: string): ParsedSSEEvent | null { - const lines = block.split(/\r?\n/); - let event = 'message'; - const dataLines: string[] = []; - - for (const line of lines) { - if (line.startsWith('event:')) { - event = line.slice('event:'.length).trim(); - } else if (line.startsWith('data:')) { - dataLines.push(line.slice('data:'.length).trimStart()); - } - } - - if (dataLines.length === 0) return null; - - const rawData = dataLines.join('\n'); - try { - return { event, data: JSON.parse(rawData) }; - } catch { - return { event, data: rawData }; - } -} - -export function consumeSSEChunk(buffer: string, chunk: string): { events: ParsedSSEEvent[]; buffer: string } { - const normalized = `${buffer}${chunk}`.replace(/\r\n/g, '\n'); - const parts = normalized.split('\n\n'); - const rest = parts.pop() ?? ''; - const events = parts - .map(parseEventBlock) - .filter((event): event is ParsedSSEEvent => event !== null); - - return { events, buffer: rest }; -} - function abortError(): DOMException { return new DOMException('Aborted', 'AbortError'); } From 18050833c08aacbae13f2d4ed87f3ca5931a95d3 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 08:03:50 +0000 Subject: [PATCH 2/5] Build the Library tab with links, notes and source details The Library lists the Current Debate's sources with search and type filters. The plus menu pastes a link through the import-source Edge Function or writes a note, saved with the website's columns and folder. A row opens the source read-only, with its page in the in-app browser, and swiping it left deletes it after a confirmation. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/package.json | 1 + .../src/app/(tabs)/(library)/library.tsx | 261 +++++++++++++++++- apps/mobile/src/app/_layout.tsx | 12 +- apps/mobile/src/app/add-link.tsx | 152 ++++++++++ apps/mobile/src/app/new-note.tsx | 101 +++++++ apps/mobile/src/app/source/[id].tsx | 126 +++++++++ apps/mobile/src/components/chip.tsx | 80 ++++++ apps/mobile/src/components/icon-paths.ts | 14 + apps/mobile/src/components/library-header.tsx | 35 +++ .../src/components/library-header.web.tsx | 78 ++++++ apps/mobile/src/components/search-field.tsx | 33 +++ apps/mobile/src/components/source-display.ts | 16 ++ apps/mobile/src/lib/confirm.ts | 11 + apps/mobile/src/lib/confirm.web.ts | 4 + apps/mobile/src/lib/sources.ts | 152 ++++++++++ apps/mobile/src/theme/colors.ts | 4 + package-lock.json | 11 + src/locales/en.json | 26 ++ src/locales/es.json | 26 ++ src/locales/hu.json | 26 ++ 20 files changed, 1162 insertions(+), 7 deletions(-) create mode 100644 apps/mobile/src/app/add-link.tsx create mode 100644 apps/mobile/src/app/new-note.tsx create mode 100644 apps/mobile/src/app/source/[id].tsx create mode 100644 apps/mobile/src/components/chip.tsx create mode 100644 apps/mobile/src/components/library-header.tsx create mode 100644 apps/mobile/src/components/library-header.web.tsx create mode 100644 apps/mobile/src/components/search-field.tsx create mode 100644 apps/mobile/src/components/source-display.ts create mode 100644 apps/mobile/src/lib/confirm.ts create mode 100644 apps/mobile/src/lib/confirm.web.ts create mode 100644 apps/mobile/src/lib/sources.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 6dc48e4bf..5cb31b52e 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -27,6 +27,7 @@ "expo-splash-screen": "~57.0.9", "expo-status-bar": "~57.0.1", "expo-system-ui": "~57.0.4", + "expo-web-browser": "~57.0.3", "i18next": "^23.16.8", "react": "19.2.3", "react-dom": "19.2.3", diff --git a/apps/mobile/src/app/(tabs)/(library)/library.tsx b/apps/mobile/src/app/(tabs)/(library)/library.tsx index 64f893fcc..e9ea3cb73 100644 --- a/apps/mobile/src/app/(tabs)/(library)/library.tsx +++ b/apps/mobile/src/app/(tabs)/(library)/library.tsx @@ -1,5 +1,260 @@ -import { PlaceholderScreen } from '@/components/placeholder-screen'; +import { useFocusEffect, useRouter } from 'expo-router'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import ReanimatedSwipeable, { type SwipeableMethods } from 'react-native-gesture-handler/ReanimatedSwipeable'; -export default function Screen() { - return ; +import { TextButton } from '@/components/buttons'; +import { Card, IconTile } from '@/components/card'; +import { Chip } from '@/components/chip'; +import { Icon } from '@/components/icon'; +import { LibraryHeader, type AddSourceKind } from '@/components/library-header'; +import { Screen } from '@/components/screen'; +import { SearchField } from '@/components/search-field'; +import { SOURCE_ICONS, sourceMeta } from '@/components/source-display'; +import { Text } from '@/components/text'; +import { useAuth } from '@/lib/auth'; +import { confirmDestructive } from '@/lib/confirm'; +import { useDebates } from '@/lib/debate-context'; +import { fetchSources, removeSource, subscribeSourcesChanged, type Source, type SourceType } from '@/lib/sources'; +import { useTheme } from '@/theme/use-theme'; + +type Filter = 'all' | SourceType; + +// In the mock's order. Files are listed read-only: the app cannot add them yet. +const FILTERS: { value: Filter; label: string }[] = [ + { value: 'all', label: 'mobile.library.filters.all' }, + { value: 'file', label: 'mobile.library.filters.files' }, + { value: 'url', label: 'mobile.library.filters.links' }, + { value: 'text', label: 'mobile.library.filters.notes' }, +]; + +function matches(source: Source, query: string): boolean { + // The website searches titles, text and origins. + const needle = query.trim().toLocaleLowerCase(); + return !needle || [source.title, source.content, source.origin ?? ''].some((value) => value.toLocaleLowerCase().includes(needle)); +} + +/** The Current Debate's sources, as in mock 04. It shows the Current Debate even in one-off mode, like the website. */ +export default function LibraryScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const { status, current } = useDebates(); + const debateId = current?.id ?? null; + + const onAdd = (kind: AddSourceKind) => router.push(kind === 'link' ? '/add-link' : '/new-note'); + + return ( + + + {status === 'loading' ? : null} + {status !== 'loading' && !debateId ? ( + + + {t('debate.libraryNeedsDebate')} + + router.push('/debates')} /> + + ) : null} + {debateId ? : null} + + ); +} + +function SourceList({ debateId }: { debateId: string }) { + const { t } = useTranslation(); + const router = useRouter(); + const { session } = useAuth(); + const { colors } = useTheme(); + const [sources, setSources] = useState(null); + const [failed, setFailed] = useState(false); + const [deleteFailed, setDeleteFailed] = useState(false); + const [query, setQuery] = useState(''); + const [filter, setFilter] = useState('all'); + const loads = useRef(0); + const userId = session?.user.id ?? null; + + const load = useCallback(async () => { + if (!userId) return; + const load = ++loads.current; + try { + const next = await fetchSources(userId, debateId); + if (load !== loads.current) return; + setSources(next); + setFailed(false); + } catch (cause) { + if (load !== loads.current) return; + console.warn('Could not load sources:', cause instanceof Error ? cause.message : cause); + setFailed(true); + } + }, [userId, debateId]); + + // Sources added on the website show up when the tab comes back into view. + useFocusEffect( + useCallback(() => { + void load(); + }, [load]), + ); + useEffect(() => subscribeSourcesChanged(() => void load()), [load]); + + const visible = useMemo( + () => (sources ?? []).filter((source) => (filter === 'all' || source.type === filter) && matches(source, query)), + [sources, filter, query], + ); + + const remove = async (source: Source) => { + if (!userId) return false; + const confirmed = await confirmDestructive({ + title: t('libraryPage.deleteSourceTitle'), + message: t('libraryPage.deleteSourceDesc'), + confirm: t('common.delete'), + cancel: t('common.cancel'), + }); + if (!confirmed) return false; + setDeleteFailed(false); + try { + await removeSource(userId, source.id); + setSources((previous) => previous?.filter((item) => item.id !== source.id) ?? previous); + return true; + } catch (cause) { + console.warn('Could not delete the source:', cause instanceof Error ? cause.message : cause); + setDeleteFailed(true); + return false; + } + }; + + if (failed && !sources) { + return ( + + + {t('mobile.library.loadFailed')} + + void load()} /> + + ); + } + if (!sources) return ; + + return ( + <> + + + {FILTERS.map((option) => ( + setFilter(option.value)} + /> + ))} + + + {deleteFailed ? ( + + {t('mobile.library.deleteFailed')} + + ) : null} + + {sources.length === 0 ? ( + + + {t('libraryPage.emptyTitle')} + + + {t('libraryPage.emptyDesc')} + + + ) : visible.length === 0 ? ( + + + {t('libraryPage.noMatchingSources')} + + + {t('libraryPage.noMatchingSourcesDesc')} + + + ) : ( + + {visible.map((source, index) => ( + router.push({ pathname: '/source/[id]', params: { id: source.id } })} + onDelete={() => remove(source)} + /> + ))} + + )} + + ); } + +/** A source row; swipe left to delete, with a confirmation, as on iOS. */ +function SourceRow({ source, first, onOpen, onDelete }: { source: Source; first: boolean; onOpen: () => void; onDelete: () => Promise }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const swipeable = useRef(null); + + const remove = async () => { + const removed = await onDelete(); + if (!removed) swipeable.current?.close(); + }; + + return ( + ( + void remove()} + style={[styles.deleteAction, { backgroundColor: colors.destructive }]}> + + + {t('common.delete')} + + + )}> + { + if (event.nativeEvent.actionName === 'delete') void remove(); + }} + onPress={onOpen} + style={({ pressed }) => [styles.row, { backgroundColor: colors.card }, pressed && styles.pressed]}> + + + + {source.title || t('libraryPage.untitledSource')} + + + {sourceMeta(t, source)} + + + + + ); +} + +const styles = StyleSheet.create({ + loading: { marginTop: 32 }, + body: { fontSize: 15, lineHeight: 21 }, + emptyTitle: { fontSize: 17 }, + chipRow: { marginHorizontal: -20, flexGrow: 0 }, + chips: { gap: 8, paddingHorizontal: 20 }, + list: { padding: 0, gap: 0, overflow: 'hidden' }, + 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 }, + deleteAction: { width: 92, alignItems: 'center', justifyContent: 'center', gap: 3 }, + deleteLabel: { color: '#ffffff', fontSize: 13 }, +}); diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 311548c0c..26fdfd9dc 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -6,7 +6,8 @@ import type { NativeStackNavigationOptions } from 'expo-router/native-stack'; import * as SplashScreen from 'expo-splash-screen'; import { StatusBar } from 'expo-status-bar'; import { useEffect } from 'react'; -import { StyleSheet, View } from 'react-native'; +import { StyleSheet } from 'react-native'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { AuthProvider, useAuth } from '@/lib/auth'; import { DebateProvider } from '@/lib/debate-context'; @@ -67,20 +68,23 @@ function Root() { return ( - {/* The screenshot script waits for auth-settled before it checks an offline cold start. */} - + {/* The screenshot script waits for auth-settled before it checks an offline cold start. Swipeable rows need the gesture root. */} + + + + - + ); } diff --git a/apps/mobile/src/app/add-link.tsx b/apps/mobile/src/app/add-link.tsx new file mode 100644 index 000000000..dd2c719c2 --- /dev/null +++ b/apps/mobile/src/app/add-link.tsx @@ -0,0 +1,152 @@ +import type { SourceImportOutput } from '@debatelab/core/ai/schemas/source-import'; +import { useRouter } from 'expo-router'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { MainActionButton, PrimaryButton, TextButton } from '@/components/buttons'; +import { Card } from '@/components/card'; +import { Field } from '@/components/field'; +import { Text } from '@/components/text'; +import { useAuth } from '@/lib/auth'; +import { useDebates } from '@/lib/debate-context'; +import { addSource, fetchSources, importSourceFromUrl, isDuplicateOrigin } from '@/lib/sources'; +import { useTheme } from '@/theme/use-theme'; + +/** + * Paste a link: the import-source Edge Function reads the page into an + * editable draft, and saving adds it to the Current Debate, as the website's + * Import from URL does. + */ +export default function AddLinkSheet() { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + const { session } = useAuth(); + const { current } = useDebates(); + const [url, setUrl] = useState(''); + const [draft, setDraft] = useState(null); + const [title, setTitle] = useState(''); + const [busy, setBusy] = useState<'import' | 'save' | null>(null); + const [error, setError] = useState(''); + + const runImport = async () => { + const trimmed = url.trim(); + if (!trimmed) { + setError(t('libraryPage.importUrlRequired')); + return; + } + setBusy('import'); + setError(''); + try { + const imported = await importSourceFromUrl(trimmed); + setDraft(imported); + setTitle(imported.title); + } catch (cause) { + setError(`${t('libraryPage.importFailed')}: ${cause instanceof Error ? cause.message : t('libraryPage.importFailedDesc')}`); + } finally { + setBusy(null); + } + }; + + const save = async () => { + const userId = session?.user.id; + if (!draft || !userId || !current || !title.trim()) return; + setBusy('save'); + setError(''); + try { + if (isDuplicateOrigin(await fetchSources(userId, current.id), draft.url)) { + setError(`${t('libraryPage.duplicateSource')}. ${t('libraryPage.duplicateSourceDesc')}`); + setBusy(null); + return; + } + await addSource(userId, { title: title.trim(), content: draft.content.trim(), type: 'url', origin: draft.url, debateId: current.id }); + router.back(); + } catch (cause) { + console.warn('Could not save the source:', cause instanceof Error ? cause.message : cause); + setError(t('mobile.library.saveFailed')); + setBusy(null); + } + }; + + return ( + + + + + {t('mobile.library.pasteLink')} + + router.back()} disabled={busy === 'save'} /> + + + {!current ? ( + + {t('debate.libraryNeedsDebate')} + + ) : ( + <> + { + setUrl(next); + setDraft(null); + }} + keyboardType="url" + autoCapitalize="none" + autoCorrect={false} + autoFocus + returnKeyType="go" + onSubmitEditing={() => void runImport()} + editable={busy === null} + /> + {!draft ? ( + void runImport()} /> + ) : ( + <> + + {t('libraryPage.importPreview')} + + + + + {`${draft.domain} · ${draft.wordCount} ${t('libraryPage.words')}`} + + {draft.excerpt ? ( + + {draft.excerpt} + + ) : null} + + void save()} /> + + )} + + )} + + {error ? ( + + {error} + + ) : null} + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1 }, + content: { paddingHorizontal: 20, paddingTop: 22, gap: 16, width: '100%', maxWidth: 640, alignSelf: 'center' }, + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, + title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, + body: { fontSize: 15, lineHeight: 21 }, + label: { fontSize: 14, marginLeft: 4, marginBottom: -8 }, + meta: { fontSize: 14 }, +}); diff --git a/apps/mobile/src/app/new-note.tsx b/apps/mobile/src/app/new-note.tsx new file mode 100644 index 000000000..4e89c5907 --- /dev/null +++ b/apps/mobile/src/app/new-note.tsx @@ -0,0 +1,101 @@ +import { useRouter } from 'expo-router'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { MainActionButton, TextButton } from '@/components/buttons'; +import { Field } from '@/components/field'; +import { Text } from '@/components/text'; +import { useAuth } from '@/lib/auth'; +import { useDebates } from '@/lib/debate-context'; +import { addSource } from '@/lib/sources'; +import { useTheme } from '@/theme/use-theme'; + +/** Write a note: a text source in the Current Debate, like the website's manual entry. */ +export default function NewNoteSheet() { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + const { session } = useAuth(); + const { current } = useDebates(); + const [title, setTitle] = useState(''); + const [content, setContent] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + const ready = Boolean(title.trim() && content.trim()); + + const save = async () => { + const userId = session?.user.id; + if (!ready || !userId || !current) return; + setSaving(true); + setError(''); + try { + await addSource(userId, { title: title.trim(), content: content.trim(), type: 'text', debateId: current.id }); + router.back(); + } catch (cause) { + console.warn('Could not save the note:', cause instanceof Error ? cause.message : cause); + setError(t('mobile.library.saveFailed')); + setSaving(false); + } + }; + + return ( + + + + + {t('mobile.library.writeNote')} + + router.back()} disabled={saving} /> + + + {!current ? ( + + {t('debate.libraryNeedsDebate')} + + ) : ( + <> + + + {error ? ( + + {error} + + ) : null} + void save()} /> + + )} + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1 }, + content: { paddingHorizontal: 20, paddingTop: 22, gap: 18, width: '100%', maxWidth: 640, alignSelf: 'center' }, + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, + title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, + body: { fontSize: 15, lineHeight: 21 }, + noteBody: { minHeight: 180, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top' }, +}); diff --git a/apps/mobile/src/app/source/[id].tsx b/apps/mobile/src/app/source/[id].tsx new file mode 100644 index 000000000..a9dc498db --- /dev/null +++ b/apps/mobile/src/app/source/[id].tsx @@ -0,0 +1,126 @@ +import { useLocalSearchParams, useRouter } from 'expo-router'; +import * as WebBrowser from 'expo-web-browser'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { TextButton } from '@/components/buttons'; +import { Card, IconTile } from '@/components/card'; +import { Icon } from '@/components/icon'; +import { SOURCE_ICONS, sourceMeta } from '@/components/source-display'; +import { Text } from '@/components/text'; +import { useAuth } from '@/lib/auth'; +import { fetchSource, type Source } from '@/lib/sources'; +import { useTheme } from '@/theme/use-theme'; + +function webUrl(origin: string | null): string | null { + return origin && /^https?:\/\//i.test(origin) ? origin : null; +} + +/** A source, read-only: its title, where it came from and its text. */ +export default function SourceSheet() { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + const { id } = useLocalSearchParams<{ id: string }>(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const [source, setSource] = useState(undefined); + const [failed, setFailed] = useState(false); + + useEffect(() => { + if (!userId) return; + let active = true; + fetchSource(userId, id) + .then((next) => { + if (active) setSource(next); + }) + .catch((cause: unknown) => { + console.warn('Could not load the source:', cause instanceof Error ? cause.message : cause); + if (active) setFailed(true); + }); + return () => { + active = false; + }; + }, [userId, id]); + + const url = webUrl(source?.origin ?? null); + + return ( + + + + {source ? ( + + + + {sourceMeta(t, source)} + + + ) : null} + + router.back()} /> + + + {failed ? ( + + {t('mobile.library.loadFailed')} + + ) : source === undefined ? ( + + ) : source === null ? ( + + {t('mobile.library.sourceMissing')} + + ) : ( + <> + + {source.title || t('libraryPage.untitledSource')} + + {url ? ( + void WebBrowser.openBrowserAsync(url)} + style={({ pressed }) => [styles.origin, { backgroundColor: colors.card, borderColor: colors.border }, pressed && styles.pressed]}> + + + {url.replace(/^https?:\/\/(www\.)?/i, '')} + + + + ) : source.origin ? ( + + {source.origin} + + ) : null} + + + {source.content} + + + + )} + + ); +} + +const styles = StyleSheet.create({ + content: { paddingHorizontal: 20, paddingTop: 18, gap: 14, width: '100%', maxWidth: 640, alignSelf: 'center' }, + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, + headerText: { flex: 1 }, + kind: { flexDirection: 'row', alignItems: 'center', gap: 9 }, + meta: { fontSize: 14, flexShrink: 1 }, + loading: { marginTop: 32 }, + body: { fontSize: 15, lineHeight: 21 }, + title: { fontSize: 23, lineHeight: 29, letterSpacing: -0.4 }, + origin: { flexDirection: 'row', alignItems: 'center', gap: 9, borderWidth: 1, borderRadius: 12, paddingHorizontal: 13, minHeight: 46 }, + originText: { flex: 1, fontSize: 15 }, + pressed: { opacity: 0.7 }, + text: { fontSize: 16, lineHeight: 24 }, +}); diff --git a/apps/mobile/src/components/chip.tsx b/apps/mobile/src/components/chip.tsx new file mode 100644 index 000000000..6680dcb3e --- /dev/null +++ b/apps/mobile/src/components/chip.tsx @@ -0,0 +1,80 @@ +import type { ReactNode } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { Icon } from './icon'; +import type { IconName } from './icon-paths'; +import { Text } from './text'; + +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +/** The mocks' pill chip: filters in Library, options and sources in Ask. */ +export function Chip({ + label, + selected, + icon, + leading, + onPress, + accessibilityRole = 'button', + testID, +}: { + label: string; + selected?: boolean; + icon?: IconName; + leading?: ReactNode; + onPress: () => void; + accessibilityRole?: 'button' | 'switch' | 'tab'; + testID?: string; +}) { + const { colors } = useTheme(); + const foreground = selected ? colors.chipSelectedForeground : colors.secondary; + return ( + [ + styles.chip, + selected + ? { backgroundColor: colors.tile, borderColor: colors.chipSelectedBorder } + : { backgroundColor: colors.input, borderColor: colors.border }, + pressed && styles.pressed, + ]}> + {leading} + {icon ? : null} + + {label} + + + ); +} + +/** The small numbered square used for citations, inline and in source chips. */ +export function CiteBadge({ n }: { n: number }) { + const { colors } = useTheme(); + return ( + + + {n} + + + ); +} + +const styles = StyleSheet.create({ + chip: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + borderWidth: 1, + borderRadius: radius.pill, + paddingHorizontal: 13, + minHeight: 36, + maxWidth: 280, + }, + label: { fontSize: 14.5, flexShrink: 1 }, + pressed: { opacity: 0.7 }, + cite: { minWidth: 20, height: 20, borderRadius: 6, paddingHorizontal: 5, alignItems: 'center', justifyContent: 'center' }, + citeText: { fontSize: 12, lineHeight: 15 }, +}); diff --git a/apps/mobile/src/components/icon-paths.ts b/apps/mobile/src/components/icon-paths.ts index b49766ec8..c3c0a30fd 100644 --- a/apps/mobile/src/components/icon-paths.ts +++ b/apps/mobile/src/components/icon-paths.ts @@ -22,6 +22,20 @@ export const iconPaths = { search: '', check: '', x: '', + chevronLeft: '', + file: '', + link: '', + note: '', + externalLink: '', + trash: '', + shield: '', + help: '', + timer: '', + history: '', + quote: '', + list: '', + map: '', + arrowUp: '', } as const; export type IconName = keyof typeof iconPaths; diff --git a/apps/mobile/src/components/library-header.tsx b/apps/mobile/src/components/library-header.tsx new file mode 100644 index 000000000..1c96e76de --- /dev/null +++ b/apps/mobile/src/components/library-header.tsx @@ -0,0 +1,35 @@ +import { Stack } from 'expo-router'; +import { useTranslation } from 'react-i18next'; + +import { useTheme } from '@/theme/use-theme'; + +export type AddSourceKind = 'link' | 'note'; + +/** + * The Library's large title and its round plus button, which opens a native + * menu. "Scan pages" and "Choose a file" from the mock are not in the app yet. + */ +export function LibraryHeader({ title, onAdd, disabled }: { title: string; onAdd: (kind: AddSourceKind) => void; disabled: boolean }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + return ( + <> + + + + onAdd('link')}> + {t('mobile.library.pasteLink')} + + onAdd('note')}> + {t('mobile.library.writeNote')} + + + + + ); +} diff --git a/apps/mobile/src/components/library-header.web.tsx b/apps/mobile/src/components/library-header.web.tsx new file mode 100644 index 000000000..a5bdda338 --- /dev/null +++ b/apps/mobile/src/components/library-header.web.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { Icon } from './icon'; +import type { IconName } from './icon-paths'; +import type { AddSourceKind } from './library-header'; +import { Text } from './text'; + +import { useTheme } from '@/theme/use-theme'; + +/** The web preview has no native header or menu, so it draws the mock's title row and menu. */ +export function LibraryHeader({ title, onAdd, disabled }: { title: string; onAdd: (kind: AddSourceKind) => void; disabled: boolean }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const [open, setOpen] = useState(false); + const items: { kind: AddSourceKind; label: string; icon: IconName }[] = [ + { kind: 'link', label: t('mobile.library.pasteLink'), icon: 'link' }, + { kind: 'note', label: t('mobile.library.writeNote'), icon: 'note' }, + ]; + return ( + + + {title} + + setOpen((value) => !value)} + testID="library-add" + style={[styles.plus, { backgroundColor: colors.primary }, disabled && styles.disabled]}> + + + {open ? ( + + {items.map((item, index) => ( + { + setOpen(false); + onAdd(item.kind); + }} + style={({ pressed }) => [ + styles.item, + index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, + pressed && { backgroundColor: colors.line }, + ]}> + {item.label} + + + ))} + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: 8, marginBottom: 2, zIndex: 10 }, + title: { fontSize: 34, letterSpacing: -1, lineHeight: 41 }, + plus: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' }, + disabled: { opacity: 0.5 }, + menu: { + position: 'absolute', + top: 50, + right: 0, + width: 252, + borderRadius: 16, + borderWidth: StyleSheet.hairlineWidth, + overflow: 'hidden', + boxShadow: '0 14px 40px -10px rgba(15,23,42,0.4)', + }, + item: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 17, minHeight: 52 }, + itemLabel: { fontSize: 17 }, +}); diff --git a/apps/mobile/src/components/search-field.tsx b/apps/mobile/src/components/search-field.tsx new file mode 100644 index 000000000..f230c87d6 --- /dev/null +++ b/apps/mobile/src/components/search-field.tsx @@ -0,0 +1,33 @@ +import { StyleSheet, TextInput, View } from 'react-native'; + +import { Icon } from './icon'; + +import { radius } from '@/theme/colors'; +import { fontFamily } from '@/theme/fonts'; +import { useTheme } from '@/theme/use-theme'; + +/** The mocks' grey search field. */ +export function SearchField({ value, onChangeText, placeholder }: { value: string; onChangeText: (text: string) => void; placeholder: string }) { + const { colors } = useTheme(); + return ( + + + + + ); +} + +const styles = StyleSheet.create({ + search: { flexDirection: 'row', alignItems: 'center', gap: 9, borderRadius: radius.field, paddingHorizontal: 13, minHeight: 46 }, + input: { flex: 1, fontSize: 16.5, fontFamily: fontFamily.regular, paddingVertical: 10 }, +}); diff --git a/apps/mobile/src/components/source-display.ts b/apps/mobile/src/components/source-display.ts new file mode 100644 index 000000000..07058b903 --- /dev/null +++ b/apps/mobile/src/components/source-display.ts @@ -0,0 +1,16 @@ +import type { TFunction } from 'i18next'; + +import type { IconName } from './icon-paths'; + +import { formatRelativeDate } from '@/lib/dates'; +import type { Source, SourceType } from '@/lib/sources'; + +export const SOURCE_ICONS: Record = { url: 'link', text: 'note', file: 'file' }; + +/** "Link, cited in 2 results" when the website counted uses, otherwise "Note, added 3 days ago". */ +export function sourceMeta(t: TFunction, source: Source): string { + const type = t(`mobile.library.types.${source.type}`); + return source.useCount > 0 + ? t('mobile.library.metaCited', { type, count: source.useCount }) + : t('mobile.library.metaAdded', { type, when: formatRelativeDate(source.createdAt) }); +} diff --git a/apps/mobile/src/lib/confirm.ts b/apps/mobile/src/lib/confirm.ts new file mode 100644 index 000000000..a12483994 --- /dev/null +++ b/apps/mobile/src/lib/confirm.ts @@ -0,0 +1,11 @@ +import { Alert } from 'react-native'; + +/** A native alert with Cancel and a destructive action. Resolves true when the action is chosen. */ +export function confirmDestructive(options: { title: string; message: string; confirm: string; cancel: string }): Promise { + return new Promise((resolve) => { + Alert.alert(options.title, options.message, [ + { text: options.cancel, style: 'cancel', onPress: () => resolve(false) }, + { text: options.confirm, style: 'destructive', onPress: () => resolve(true) }, + ], { cancelable: true, onDismiss: () => resolve(false) }); + }); +} diff --git a/apps/mobile/src/lib/confirm.web.ts b/apps/mobile/src/lib/confirm.web.ts new file mode 100644 index 000000000..2aeeea491 --- /dev/null +++ b/apps/mobile/src/lib/confirm.web.ts @@ -0,0 +1,4 @@ +/** react-native-web's Alert does nothing, so the web preview asks with the browser's dialog. */ +export function confirmDestructive(options: { title: string; message: string; confirm: string; cancel: string }): Promise { + return Promise.resolve(window.confirm(`${options.title}\n\n${options.message}`)); +} diff --git a/apps/mobile/src/lib/sources.ts b/apps/mobile/src/lib/sources.ts new file mode 100644 index 000000000..f4027fb94 --- /dev/null +++ b/apps/mobile/src/lib/sources.ts @@ -0,0 +1,152 @@ +import { SourceImportOutputSchema, type SourceImportOutput } from '@debatelab/core/ai/schemas/source-import'; +import { getEdgeFunctionErrorMessage } from '@debatelab/core/supabase/edge-function-error'; + +import { supabase } from './supabase'; + +/** + * Library sources, read and written the way the website's session store + * (src/lib/stores/session-store.ts: addSource, removeSource) and + * src/lib/source-import.ts do. A source belongs to one debate. + */ + +export type SourceType = 'url' | 'file' | 'text'; + +export interface Source { + id: string; + title: string; + content: string; + type: SourceType; + origin: string | null; + createdAt: number; + /** Results that used this source, from the website's source assessments. */ + useCount: number; +} + +const SOURCE_COLUMNS = 'id,title,content,type,origin,metadata,created_at'; +const DEFAULT_FOLDER = 'General'; + +interface SourceRow { + id: string; + title: string; + content: string; + type: string; + origin: string | null; + metadata: unknown; + created_at: string; +} + +function timesUsed(metadata: unknown): number { + if (!metadata || typeof metadata !== 'object') return 0; + const semantic = (metadata as { semantic?: { useCount?: unknown } }).semantic; + const count = semantic?.useCount; + return typeof count === 'number' && Number.isInteger(count) && count > 0 ? count : 0; +} + +function fromRow(row: SourceRow): Source { + return { + id: row.id, + title: row.title, + content: row.content, + type: row.type === 'url' || row.type === 'file' ? row.type : 'text', + origin: row.origin || null, + createdAt: new Date(row.created_at).getTime(), + useCount: timesUsed(row.metadata), + }; +} + +/** + * 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. + */ + +/** One debate's sources, newest first, as the website's Library lists them by default. */ +export async function fetchSources(userId: string, debateId: string): Promise { + const { data, error } = await supabase + .from('sources') + .select(SOURCE_COLUMNS) + .eq('user_id', userId) + .eq('debate_id', debateId) + .order('created_at', { ascending: false }); + if (error) throw new Error(error.message); + return ((data ?? []) as SourceRow[]).map(fromRow); +} + +export async function fetchSource(userId: string, id: string): Promise { + const { data, error } = await supabase + .from('sources') + .select(SOURCE_COLUMNS) + .eq('user_id', userId) + .eq('id', id) + .maybeSingle(); + if (error) throw new Error(error.message); + return data ? fromRow(data as SourceRow) : null; +} + +/** Saves a source to a debate in the General folder, as the website's Library does. */ +export async function addSource( + userId: string, + input: { title: string; content: string; type: SourceType; origin?: string | null; debateId: string }, +): Promise { + const folderResult = await supabase + .from('source_folders') + .upsert({ user_id: userId, name: DEFAULT_FOLDER }, { onConflict: 'user_id,name' }); + if (folderResult.error) throw new Error(folderResult.error.message); + const { data, error } = await supabase + .from('sources') + .insert({ + user_id: userId, + debate_id: input.debateId, + title: input.title, + content: input.content, + type: input.type, + origin: input.origin || null, + folder: DEFAULT_FOLDER, + is_active: true, + metadata: null, + }) + .select(SOURCE_COLUMNS) + .single(); + if (error || !data) throw new Error(error?.message ?? 'Could not save the source'); + notifySourcesChanged(); + return fromRow(data as SourceRow); +} + +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); + notifySourcesChanged(); +} + +/** 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(); + return Boolean(needle) && sources.some((source) => source.origin?.trim().toLowerCase() === needle); +} + +/** Reads a page through the import-source Edge Function, which returns an editable draft. */ +export async function importSourceFromUrl(url: string): Promise { + const { data: sessionData } = await supabase.auth.getSession(); + const accessToken = sessionData.session?.access_token; + const { data, error } = await supabase.functions.invoke('import-source', { + headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, + body: { url }, + }); + if (error) throw new Error((await getEdgeFunctionErrorMessage(error)) || 'Failed to import source'); + const parsed = SourceImportOutputSchema.safeParse(data); + if (!parsed.success) throw new Error('Imported source response was invalid'); + return parsed.data; +} + +// Screens showing sources reload when another screen adds or removes one. +const listeners = new Set<() => void>(); + +export function subscribeSourcesChanged(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function notifySourcesChanged() { + listeners.forEach((listener) => listener()); +} diff --git a/apps/mobile/src/theme/colors.ts b/apps/mobile/src/theme/colors.ts index 4b7eca48c..883579aaa 100644 --- a/apps/mobile/src/theme/colors.ts +++ b/apps/mobile/src/theme/colors.ts @@ -16,6 +16,8 @@ export const palette = { onPrimary: '#ffffff', tile: '#e0f2fe', tileForeground: '#0284c7', + chipSelectedBorder: '#7dd3fc', + chipSelectedForeground: '#075985', segmentTrack: '#e2e8f0', segmentSelected: '#ffffff', input: '#ffffff', @@ -36,6 +38,8 @@ export const palette = { onPrimary: '#082f49', tile: '#15304a', tileForeground: '#7dd3fc', + chipSelectedBorder: '#0369a1', + chipSelectedForeground: '#bae6fd', segmentTrack: '#1e293b', segmentSelected: '#334155', input: '#0f172a', diff --git a/package-lock.json b/package-lock.json index ca545ac95..3f22ef75d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -136,6 +136,7 @@ "expo-splash-screen": "~57.0.9", "expo-status-bar": "~57.0.1", "expo-system-ui": "~57.0.4", + "expo-web-browser": "~57.0.3", "i18next": "^23.16.8", "react": "19.2.3", "react-dom": "19.2.3", @@ -825,6 +826,16 @@ } } }, + "apps/mobile/node_modules/expo-web-browser": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-57.0.3.tgz", + "integrity": "sha512-+ecvhyS/PdWkpv4ai6FF1Trd6Y/4kn/eQ3O86mie2ecU3w+I8bKZh13kppY/jw1uSL7yikt03L5W/iiHojrFwQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "apps/mobile/node_modules/expo/node_modules/@expo/cli": { "version": "57.0.28", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.28.tgz", diff --git a/src/locales/en.json b/src/locales/en.json index 2ce586d23..57f115f00 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2446,6 +2446,32 @@ }, "deleteAccount": { "failedHint": "Your account is still here. Check your connection and try again." + }, + "library": { + "add": "Add a source", + "pasteLink": "Paste a link", + "writeNote": "Write a note", + "search_one": "Search {{count}} source", + "search_other": "Search {{count}} sources", + "filters": { + "all": "All", + "files": "Files", + "links": "Links", + "notes": "Notes" + }, + "types": { + "url": "Link", + "text": "Note", + "file": "File" + }, + "metaAdded": "{{type}}, added {{when}}", + "metaCited_one": "{{type}}, cited in {{count}} result", + "metaCited_other": "{{type}}, cited in {{count}} results", + "loadFailed": "Could not load your sources.", + "deleteFailed": "Could not delete the source. Try again.", + "saveFailed": "Could not save the source. Try again.", + "sourceMissing": "This source is no longer in your Library.", + "openPage": "Opens the page in the browser" } } } diff --git a/src/locales/es.json b/src/locales/es.json index a8d2c96d6..7ed8a016e 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2446,6 +2446,32 @@ }, "deleteAccount": { "failedHint": "Tu cuenta sigue activa. Comprueba tu conexión e inténtalo de nuevo." + }, + "library": { + "add": "Añadir una fuente", + "pasteLink": "Pegar un enlace", + "writeNote": "Escribir una nota", + "search_one": "Buscar en {{count}} fuente", + "search_other": "Buscar en {{count}} fuentes", + "filters": { + "all": "Todas", + "files": "Archivos", + "links": "Enlaces", + "notes": "Notas" + }, + "types": { + "url": "Enlace", + "text": "Nota", + "file": "Archivo" + }, + "metaAdded": "{{type}}, se añadió {{when}}", + "metaCited_one": "{{type}}, aparece en {{count}} resultado", + "metaCited_other": "{{type}}, aparece en {{count}} resultados", + "loadFailed": "No se pudieron cargar tus fuentes.", + "deleteFailed": "No se pudo eliminar la fuente. Inténtalo de nuevo.", + "saveFailed": "No se pudo guardar la fuente. Inténtalo de nuevo.", + "sourceMissing": "Esta fuente ya no está en tu biblioteca.", + "openPage": "Abre la página en el navegador" } } } diff --git a/src/locales/hu.json b/src/locales/hu.json index d17391164..a81347912 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2446,6 +2446,32 @@ }, "deleteAccount": { "failedHint": "A fiókod megmaradt. Ellenőrizd a kapcsolatot, és próbáld újra." + }, + "library": { + "add": "Forrás hozzáadása", + "pasteLink": "Link beillesztése", + "writeNote": "Jegyzet írása", + "search_one": "Keresés {{count}} forrás között", + "search_other": "Keresés {{count}} forrás között", + "filters": { + "all": "Mind", + "files": "Fájlok", + "links": "Linkek", + "notes": "Jegyzetek" + }, + "types": { + "url": "Link", + "text": "Jegyzet", + "file": "Fájl" + }, + "metaAdded": "{{type}}, hozzáadva: {{when}}", + "metaCited_one": "{{type}}, {{count}} eredményben hivatkozva", + "metaCited_other": "{{type}}, {{count}} eredményben hivatkozva", + "loadFailed": "Nem sikerült betölteni a forrásaidat.", + "deleteFailed": "Nem sikerült törölni a forrást. Próbáld újra.", + "saveFailed": "Nem sikerült menteni a forrást. Próbáld újra.", + "sourceMissing": "Ez a forrás már nincs a könyvtáradban.", + "openPage": "Megnyitja az oldalt a böngészőben" } } } From 3ac2f2f814a2b5b393108dd9788bd6898ca83dbd Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 08:18:24 +0000 Subject: [PATCH 3/5] Add Ask with streaming answers, reached from the Practice tab Practice lists the website's practice tools; Ask opens full screen and the rest say they are on their way. Ask sends the website's request: plain questions stream from ai-stream through expo/fetch, and with Citations, Web search, Pros/Cons or Argument map on the whole answer comes from the ai function, because ai-stream refuses those options. The model, settings and Current Debate context are picked the way the website picks them, including pins. Finished exchanges are saved to chats and messages, past chats reopen from the history sheet, and leaving mid-answer cancels it without saving half a message. Answers render through a small Markdown reader with citation chips that open the Library source or the cited page. Co-Authored-By: Claude Opus 5.5 --- .../src/app/(tabs)/(practice)/practice.tsx | 53 +- .../src/app/(tabs)/(practice)/soon/[tool].tsx | 13 + apps/mobile/src/app/_layout.tsx | 2 + apps/mobile/src/app/ask.tsx | 500 ++++++++++++++++++ apps/mobile/src/components/ask-answer.tsx | 136 +++++ apps/mobile/src/components/ask-history.tsx | 125 +++++ apps/mobile/src/components/markdown-view.tsx | 151 ++++++ .../src/components/placeholder-screen.tsx | 11 +- apps/mobile/src/components/practice-tools.ts | 12 + apps/mobile/src/lib/ask-config.ts | 94 ++++ apps/mobile/src/lib/ask-context.ts | 120 +++++ apps/mobile/src/lib/ask.ts | 283 ++++++++++ apps/mobile/src/lib/chats.ts | 114 ++++ apps/mobile/src/lib/markdown.test.ts | 48 ++ apps/mobile/src/lib/markdown.ts | 214 ++++++++ src/locales/en.json | 18 + src/locales/es.json | 18 + src/locales/hu.json | 18 + 18 files changed, 1922 insertions(+), 8 deletions(-) create mode 100644 apps/mobile/src/app/(tabs)/(practice)/soon/[tool].tsx create mode 100644 apps/mobile/src/app/ask.tsx create mode 100644 apps/mobile/src/components/ask-answer.tsx create mode 100644 apps/mobile/src/components/ask-history.tsx create mode 100644 apps/mobile/src/components/markdown-view.tsx create mode 100644 apps/mobile/src/components/practice-tools.ts create mode 100644 apps/mobile/src/lib/ask-config.ts create mode 100644 apps/mobile/src/lib/ask-context.ts create mode 100644 apps/mobile/src/lib/ask.ts create mode 100644 apps/mobile/src/lib/chats.ts create mode 100644 apps/mobile/src/lib/markdown.test.ts create mode 100644 apps/mobile/src/lib/markdown.ts diff --git a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx index 937cd3d41..1ea88571b 100644 --- a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx +++ b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx @@ -1,5 +1,52 @@ -import { PlaceholderScreen } from '@/components/placeholder-screen'; +import { useRouter } from 'expo-router'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, View } from 'react-native'; -export default function Screen() { - return ; +import { Card, IconTile } from '@/components/card'; +import { Icon } from '@/components/icon'; +import { PRACTICE_TOOLS } from '@/components/practice-tools'; +import { ScreenTitle } from '@/components/screen-title'; +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. */ +export default function PracticeScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const { colors } = useTheme(); + return ( + + + + {PRACTICE_TOOLS.map((tool, index) => ( + (tool.key === 'ask' ? router.push('/ask') : router.push({ pathname: '/soon/[tool]', params: { 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}`)} + + + + + ))} + + + ); } + +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 }, +}); diff --git a/apps/mobile/src/app/(tabs)/(practice)/soon/[tool].tsx b/apps/mobile/src/app/(tabs)/(practice)/soon/[tool].tsx new file mode 100644 index 000000000..4318cbcd9 --- /dev/null +++ b/apps/mobile/src/app/(tabs)/(practice)/soon/[tool].tsx @@ -0,0 +1,13 @@ +import { useLocalSearchParams } from 'expo-router'; +import { useTranslation } from 'react-i18next'; + +import { PlaceholderScreen } from '@/components/placeholder-screen'; +import { PRACTICE_TOOLS } from '@/components/practice-tools'; + +/** A Practice tool that is not in the app yet. */ +export default function PracticeToolScreen() { + const { t } = useTranslation(); + const { tool } = useLocalSearchParams<{ tool: string }>(); + const entry = PRACTICE_TOOLS.find((item) => item.key === tool); + return ; +} diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 26fdfd9dc..6691de4f6 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -79,6 +79,8 @@ function Root() { + {/* Full screen above the tabs, as in mock 07. */} + diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx new file mode 100644 index 000000000..e43a53b63 --- /dev/null +++ b/apps/mobile/src/app/ask.tsx @@ -0,0 +1,500 @@ +import type { AskOutput } from '@debatelab/core/ai/schemas/ask'; +import { 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'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { AskAnswer } from '@/components/ask-answer'; +import { AskHistory } from '@/components/ask-history'; +import { TextButton } from '@/components/buttons'; +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 { useAuth } from '@/lib/auth'; +import { fetchChat, saveChat, type ChatMessage, type ChatRecord } from '@/lib/chats'; +import { useDebates } from '@/lib/debate-context'; +import { useSettings } from '@/lib/settings'; +import { fetchSources, type Source } from '@/lib/sources'; +import { radius } from '@/theme/colors'; +import { fontFamily } from '@/theme/fonts'; +import { useTheme } from '@/theme/use-theme'; + +const NO_OPTIONS: AskOptions = { citationMode: false, webSearch: false, prosConsMode: false, argumentMapMode: false }; +const NEW_CHAT: ChatRecord = { chatId: null, debateId: null, persisted: 0 }; + +// The web's toggles as one row of chips above the composer (mock 07). +const OPTION_CHIPS: { key: keyof AskOptions; label: string; icon: IconName }[] = [ + { key: 'citationMode', label: 'askChat.citations', icon: 'quote' }, + { key: 'webSearch', label: 'askChat.webSearch', icon: 'globe' }, + { key: 'prosConsMode', label: 'askChat.modeProsCons', icon: 'list' }, + { key: 'argumentMapMode', label: 'askChat.modeArgMap', icon: 'map' }, +]; + +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']); + +let localId = 0; +const nextId = () => `local-${Date.now()}-${(localId += 1)}`; + +/** Ask, as in mock 07: a coaching chat that streams, grounded in the Current Debate. */ +export default function AskScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const { current, oneOff, activeDebateId, refresh } = useDebates(); + const { outputLanguage } = useSettings(); + + const [messages, setMessages] = useState([]); + const [streaming, setStreaming] = useState(null); + const [loading, setLoading] = useState(false); + const [opening, setOpening] = useState(false); + 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 [notice, setNotice] = useState(''); + const [historyOpen, setHistoryOpen] = useState(false); + const [library, setLibrary] = useState([]); + + const scroll = useRef(null); + const requests = useRef(0); + const abort = useRef(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); + const saves = useRef>(Promise.resolve()); + const activeDebateRef = useRef(activeDebateId); + activeDebateRef.current = activeDebateId; + + const setRecord = (next: ChatRecord) => { + recordRef.current = next; + setRecordState(next); + }; + + const askConfig = () => { + if (!config.current && userId) config.current = fetchAskConfig(userId); + return config.current!; + }; + + const abortActive = useCallback(() => { + requests.current += 1; + abort.current?.abort(); + abort.current = null; + }, []); + + // Leaving Ask cancels an answer still on its way, so nothing half-written is saved. + useEffect(() => () => abortActive(), [abortActive]); + + const startConversation = (messagesNext: ChatMessage[], recordNext: ChatRecord) => { + abortActive(); + conversation.current += 1; + setMessages(messagesNext); + setRecord(recordNext); + setStreaming(null); + setLoading(false); + setOpening(false); + setError(null); + setNotice(''); + }; + + // Another debate, or one-off mode, starts a fresh conversation; an open chat in the new scope stays. + const scope = oneOff ? null : current?.id ?? null; + const scopeRef = useRef(scope); + useEffect(() => { + if (scopeRef.current === scope) return; + scopeRef.current = scope; + const open = recordRef.current; + if (!(open.chatId && open.debateId === scope)) startConversation([], NEW_CHAT); + // startConversation only touches refs and setters. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scope]); + + // 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; + } + let active = true; + fetchSources(userId, chatDebateId) + .then((next) => { + if (active) setLibrary(next); + }) + .catch((cause: unknown) => console.warn('Could not load sources for citations:', cause instanceof Error ? cause.message : cause)); + return () => { + active = false; + }; + }, [userId, chatDebateId]); + + // Each finished exchange is saved to the debate, as on the website. + const lastRole = messages[messages.length - 1]?.role; + useEffect(() => { + if (loading || lastRole !== 'assistant' || !userId) return; + const snapshot = messages; + const owner = conversation.current; + saves.current = saves.current.then(async () => { + if (owner !== conversation.current || recordRef.current.persisted >= snapshot.length) return; + try { + const next = await saveChat(userId, recordRef.current, snapshot, activeDebateRef.current); + if (next && owner === conversation.current) setRecord(next); + } catch (cause) { + console.warn('Could not save the chat:', cause instanceof Error ? cause.message : cause); + if (owner === conversation.current) setNotice(t('debate.recordFailed')); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loading, lastRole, messages, userId]); + + const send = async (text: string, history: ChatMessage[]) => { + const message = text.trim(); + if (!message || !userId) return; + abortActive(); + const request = requests.current; + const isCurrent = () => requests.current === request; + if (!isMessageWithinBudget(message)) { + setError({ kind: 'tooLarge', question: message }); + return; + } + const requestOptions = options; + const controller = new AbortController(); + abort.current = controller; + setMessages([...history, { id: nextId(), role: 'user', content: message, timestamp: Date.now() }]); + setInput(''); + setError(null); + setNotice(''); + setLoading(true); + const started = Date.now(); + + try { + const open = recordRef.current; + const debateId = open.chatId ? open.debateId : activeDebateRef.current; + const [askSettings, debateContext] = await Promise.all([ + askConfig(), + debateId ? buildAskDebateContext(userId, debateId, open.chatId) : Promise.resolve(undefined), + ]); + if (!isCurrent()) return; + const askRequest = { + message, + history: history.map((item) => ({ role: item.role, content: item.content })), + options: requestOptions, + config: askSettings, + language: outputLanguage, + debateContext, + }; + + let result: AskOutput; + if (canStream(requestOptions)) { + let received = ''; + let pending = ''; + let flush: ReturnType | null = null; + setStreaming(''); + try { + result = await streamAsk(askRequest, { + signal: controller.signal, + onDelta: (delta) => { + if (!isCurrent()) return; + received += delta; + pending += delta; + // Tokens arrive faster than the screen needs; paint every 30 ms like the website. + flush ??= setTimeout(() => { + flush = null; + const batch = pending; + pending = ''; + if (isCurrent()) setStreaming((previous) => (previous ?? '') + batch); + }, 30); + }, + }); + } catch (streamError) { + if (isAbortError(streamError) || !isCurrent()) throw streamError; + if (received) { + setNotice(`${t('ask.streamInterrupted')} ${t('ask.streamInterruptedDesc')}`); + result = { response: received.trim(), followUpQuestions: [], uncertainties: [t('ask.streamInterrupted')] }; + } else if (isFinalAskError(streamError)) { + throw streamError; + } else { + // Nothing arrived: ask once more without streaming, as the website does. + setStreaming(null); + result = await askOnce(askRequest, controller.signal); + } + } finally { + if (flush) clearTimeout(flush); + } + } else { + result = await askOnce(askRequest, controller.signal); + } + + if (!isCurrent()) return; + const output = { + ...result, + model: result.model || askSettings.model, + provider: result.provider || 'openrouter', + durationMs: result.durationMs || Date.now() - started, + generatedAt: result.generatedAt || new Date().toISOString(), + }; + setStreaming(null); + setMessages((previous) => [...previous, { id: nextId(), role: 'assistant', content: result.response, timestamp: Date.now(), output }]); + } 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); + // A missing debate was deleted elsewhere; reload the list so the app stops using it. + if (kind === 'debateMissing') void refresh(); + setError({ kind, question: message }); + } finally { + if (isCurrent()) { + setLoading(false); + setStreaming(null); + abort.current = null; + } + } + }; + + // Retrying replaces the unanswered question instead of asking it twice. + const retry = (question: string) => { + const last = messages[messages.length - 1]; + void send(question, last?.role === 'user' ? messages.slice(0, -1) : messages); + }; + + const openChat = async (chatId: string) => { + if (!userId) return; + setHistoryOpen(false); + startConversation([], NEW_CHAT); + const owner = conversation.current; + setOpening(true); + try { + const chat = await fetchChat(userId, chatId); + if (owner !== conversation.current) return; + setMessages(chat.messages); + setRecord({ chatId, debateId: chat.debateId, persisted: chat.messages.length }); + } catch (cause) { + console.warn('Could not open the chat:', cause instanceof Error ? cause.message : cause); + if (owner === conversation.current) setNotice(t('mobile.ask.openFailed')); + } finally { + if (owner === conversation.current) setOpening(false); + } + }; + + const subtitle = oneOff ? t('debate.oneOffActive') : current?.motion ?? ''; + const busy = loading || opening; + + return ( + + + router.back()} style={styles.headerButton}> + + + + + {t('nav.ask')} + + {subtitle ? ( + + {subtitle} + + ) : null} + + setHistoryOpen(true)} + style={styles.headerButton} + testID="ask-history-button"> + + + + + scroll.current?.scrollToEnd({ animated: true })}> + {messages.length === 0 && streaming === null && !busy ? ( + + + {t('askChat.startConversation')} + + + {t('askChat.startDesc')} + + + ) : null} + + {messages.map((message) => + message.role === 'user' ? ( + + + {message.content} + + + ) : ( + + ), + )} + + {streaming !== null && streaming !== '' ? : null} + {loading && !streaming ? : null} + {opening ? : null} + + {notice ? ( + + {notice} + + ) : null} + + {error ? ( + + + {t('ask.failed')} + + + {t(`mobile.ask.errors.${error.kind}`)} + + + {RETRYABLE_ERRORS.has(error.kind) ? retry(error.question)} /> : null} + {PLAN_ERRORS.has(error.kind) ? router.navigate('/you')} /> : null} + + + ) : null} + + + + + + {OPTION_CHIPS.map((chip) => ( + setOptions((previous) => ({ ...previous, [chip.key]: !previous[chip.key] }))} + /> + ))} + + + + void send(input, messages)} + style={[styles.send, { backgroundColor: colors.primary }, (busy || !input.trim()) && styles.sendDisabled]} + testID="ask-send"> + {busy ? : } + + + + + + setHistoryOpen(false)} + onPick={(chatId) => void openChat(chatId)} + onNewChat={() => { + setHistoryOpen(false); + startConversation([], NEW_CHAT); + }} + /> + + ); +} + +/** What the coach is doing while a whole answer is prepared, cycling like the website's. */ +function Thinking() { + const { t } = useTranslation(); + const [index, setIndex] = useState(0); + useEffect(() => { + const timer = setInterval(() => setIndex((value) => (value + 1) % THINKING.length), 3000); + return () => clearInterval(timer); + }, []); + return ( + + + + {t(THINKING[index])} + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1 }, + header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingBottom: 10, borderBottomWidth: StyleSheet.hairlineWidth }, + headerButton: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, + headerText: { flex: 1, alignItems: 'center' }, + headerTitle: { fontSize: 17, lineHeight: 22 }, + headerSubtitle: { fontSize: 13.5, lineHeight: 18 }, + messages: { flex: 1 }, + messagesContent: { paddingHorizontal: 20, paddingTop: 18, paddingBottom: 24, gap: 16, width: '100%', maxWidth: 720, alignSelf: 'center' }, + empty: { alignItems: 'center', gap: 6, paddingTop: 48, paddingHorizontal: 12 }, + emptyTitle: { fontSize: 18 }, + emptyBody: { fontSize: 15.5, lineHeight: 22, textAlign: 'center' }, + bubble: { alignSelf: 'flex-end', maxWidth: '80%', paddingHorizontal: 16, paddingVertical: 11, borderTopLeftRadius: 20, borderTopRightRadius: 20, borderBottomLeftRadius: 20, borderBottomRightRadius: 6 }, + bubbleText: { fontSize: 17, lineHeight: 24 }, + opening: { marginTop: 32 }, + thinking: { flexDirection: 'row', alignItems: 'center', gap: 10 }, + thinkingText: { fontSize: 15 }, + notice: { fontSize: 14, lineHeight: 19 }, + error: { borderWidth: 1, borderRadius: radius.card, padding: 14, gap: 4 }, + errorTitle: { fontSize: 16 }, + errorBody: { fontSize: 15, lineHeight: 21 }, + errorActions: { flexDirection: 'row', gap: 20 }, + composer: { borderTopWidth: StyleSheet.hairlineWidth, paddingTop: 10 }, + // The same centred column as the messages, so the composer lines up on iPad. + composerColumn: { width: '100%', maxWidth: 720, alignSelf: 'center', paddingHorizontal: 16, gap: 10 }, + chipRow: { flexGrow: 0, marginHorizontal: -16 }, + chips: { gap: 8, paddingHorizontal: 16 }, + inputRow: { + flexDirection: 'row', + alignItems: 'center', + borderWidth: 1, + borderRadius: 24, + paddingLeft: 16, + paddingRight: 6, + paddingVertical: 6, + minHeight: 48, + }, + input: { flex: 1, fontSize: 17, fontFamily: fontFamily.regular, paddingTop: 7, paddingBottom: 7, maxHeight: 120 }, + send: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' }, + sendDisabled: { opacity: 0.45 }, +}); diff --git a/apps/mobile/src/components/ask-answer.tsx b/apps/mobile/src/components/ask-answer.tsx new file mode 100644 index 000000000..9e2205200 --- /dev/null +++ b/apps/mobile/src/components/ask-answer.tsx @@ -0,0 +1,136 @@ +import type { AskOutput } from '@debatelab/core/ai/schemas/ask'; +import { useRouter } from 'expo-router'; +import * as WebBrowser from 'expo-web-browser'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView, StyleSheet, View } from 'react-native'; + +import { Chip, CiteBadge } from './chip'; +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) => { + const n = index + 1; + const source = citation.source?.trim() ?? ''; + const url = firstUrl(source); + if (url) { + const found = output?.searchInfo?.sources.find((item) => item.url === url); + const named = source.replace(url, '').replace(/[\s,:;()–-]+$/u, '').replace(/^[\s,:;()–-]+/u, ''); + return { n, label: shorten(found?.title || named || hostOf(url)), url }; + } + const needle = source.toLocaleLowerCase(); + const match = needle ? library.find((item) => item.title.trim() && needle.includes(item.title.trim().toLocaleLowerCase())) : undefined; + if (match) return { n, label: shorten(match.title), sourceId: match.id }; + return { n, label: shorten(source || citation.claim) }; + }); +} + +/** One coach answer, as in mock 07, with the website's extra sections when the answer has them. */ +export function AskAnswer({ content, output, streaming, library }: { content: string; output?: AskOutput; streaming?: boolean; library: Source[] }) { + const { t } = useTranslation(); + const router = useRouter(); + const targets = useMemo(() => citationTargets(output, library), [output, library]); + const claims = useMemo(() => (output?.citations ?? []).map((citation) => citation.claim), [output]); + 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); + }; + + return ( + + {content || streaming ? ( + open(targets[n - 1])} /> + ) : null} + + {targets.length > 0 || webSources.length > 0 ? ( + + {targets.map((target) => ( + } onPress={() => open(target)} /> + ))} + {webSources.map((item) => ( + void WebBrowser.openBrowserAsync(item.url)} /> + ))} + + ) : null} + + {output?.prosConsAnalysis ? ( + +
+
+ + ) : null} + + {output?.argumentMap ? ( + + + {t('askChat.argumentMap')} + +
+
+
+
+ + ) : null} + + {output?.followUpQuestions.length ?
: null} + {output?.uncertainties.length ?
: null} + + ); +} + +function Section({ title, items, marker = '•' }: { title: string; items: string[]; marker?: string }) { + if (items.length === 0) return null; + return ( + + + {title} + + {items.map((item, index) => ( + + {`${marker} ${item}`} + + ))} + + ); +} + +const styles = StyleSheet.create({ + answer: { gap: 12 }, + chipRow: { marginHorizontal: -20, flexGrow: 0 }, + chips: { gap: 8, paddingHorizontal: 20 }, + section: { gap: 10 }, + sectionBody: { gap: 3 }, + sectionTitle: { fontSize: 14 }, + sectionItem: { fontSize: 15, lineHeight: 21 }, +}); diff --git a/apps/mobile/src/components/ask-history.tsx b/apps/mobile/src/components/ask-history.tsx new file mode 100644 index 000000000..8d8fb4c75 --- /dev/null +++ b/apps/mobile/src/components/ask-history.tsx @@ -0,0 +1,125 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, Modal, Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { PrimaryButton, TextButton } from './buttons'; +import { Card } from './card'; +import { Icon } from './icon'; +import { Text } from './text'; + +import { useAuth } from '@/lib/auth'; +import { fetchChats, type ChatSummary } from '@/lib/chats'; +import { formatDate } from '@/lib/dates'; +import { useTheme } from '@/theme/use-theme'; + +/** Past Ask chats in this debate (or the unsorted ones in one-off mode), in a native page sheet. */ +export function AskHistory({ + visible, + debateId, + openChatId, + onClose, + onPick, + onNewChat, +}: { + visible: boolean; + debateId: string | null; + openChatId: string | null; + onClose: () => void; + onPick: (chatId: string) => void; + onNewChat: () => void; +}) { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const [chats, setChats] = useState(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + if (!visible || !userId) return; + let active = true; + setChats(null); + setFailed(false); + fetchChats(userId, debateId) + .then((next) => { + if (active) setChats(next); + }) + .catch((cause: unknown) => { + console.warn('Could not load chats:', cause instanceof Error ? cause.message : cause); + if (active) setFailed(true); + }); + return () => { + active = false; + }; + }, [visible, userId, debateId]); + + return ( + + + + + {t('askChat.savedChats')} + + + + + {failed ? ( + + {t('mobile.ask.historyFailed')} + + ) : chats === null ? ( + + ) : chats.length === 0 ? ( + + + {t('askChat.noSavedChats')} + + + {t('askChat.noSavedChatsDesc')} + + + ) : ( + + {chats.map((chat, index) => ( + onPick(chat.id)} + style={({ pressed }) => [styles.row, index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> + + + {chat.title} + + + {`${formatDate(new Date(chat.createdAt).toISOString())} · ${t('askChat.messageCount', { count: chat.messageCount })}`} + + + {chat.id === openChatId ? : null} + + ))} + + )} + + + ); +} + +const styles = StyleSheet.create({ + content: { paddingHorizontal: 20, paddingTop: 22, gap: 14, width: '100%', maxWidth: 640, alignSelf: 'center' }, + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, + title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, + body: { fontSize: 15, lineHeight: 21 }, + loading: { marginTop: 24 }, + list: { padding: 0, gap: 0 }, + row: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 13, paddingHorizontal: 16 }, + rowText: { flex: 1, gap: 3 }, + rowTitle: { fontSize: 16, lineHeight: 21 }, + meta: { fontSize: 14 }, + pressed: { opacity: 0.6 }, +}); diff --git a/apps/mobile/src/components/markdown-view.tsx b/apps/mobile/src/components/markdown-view.tsx new file mode 100644 index 000000000..de2b0a15c --- /dev/null +++ b/apps/mobile/src/components/markdown-view.tsx @@ -0,0 +1,151 @@ +import * as WebBrowser from 'expo-web-browser'; +import { Fragment, useMemo, type ReactNode } from 'react'; +import { StyleSheet, Text as NativeText, View } from 'react-native'; + +import { CiteBadge } from './chip'; +import { Text } from './text'; + +import { attachCitations, parseMarkdown, type Block, type Inline } from '@/lib/markdown'; +import { fontFamily } from '@/theme/fonts'; +import { useTheme } from '@/theme/use-theme'; + +/** + * An Ask answer as readable text, as in mock 07: paragraphs, bold lead-ins, + * lists and numbered citation chips. `claims` are the citations' claims in + * order; a chip is placed where the answer quotes one. While streaming, a + * caret follows the last word. + */ +export function MarkdownView({ + source, + claims = [], + streaming, + onCite, +}: { + source: string; + claims?: string[]; + streaming?: boolean; + onCite?: (n: number) => void; +}) { + const { colors } = useTheme(); + const blocks = useMemo(() => attachCitations(parseMarkdown(source), claims), [source, claims]); + const caret = ; + + const inline = (nodes: Inline[], key = ''): ReactNode[] => + nodes.map((node, index) => { + const id = `${key}${index}`; + switch (node.type) { + case 'text': + return node.text; + case 'bold': + return ( + + {inline(node.children, `${id}.`)} + + ); + case 'italic': + return ( + + {inline(node.children, `${id}.`)} + + ); + case 'code': + return ( + + {node.text} + + ); + case 'link': + return ( + void WebBrowser.openBrowserAsync(node.url)} style={{ color: colors.primary, textDecorationLine: 'underline' }}> + {node.text} + + ); + case 'cite': + return ( + onCite(node.n) : undefined}> + {' '} + + + ); + } + }); + + const last = blocks.length - 1; + const render = (block: Block, index: number) => { + const tail = streaming && index === last ? caret : null; + switch (block.type) { + case 'paragraph': + return ( + + {inline(block.inline)} + {tail} + + ); + case 'heading': + return ( + + {inline(block.inline)} + {tail} + + ); + case 'quote': + return ( + + + {inline(block.inline)} + {tail} + + + ); + case 'code': + return ( + + {block.text} + {tail} + + ); + case 'rule': + return ; + case 'list': + return ( + + {block.items.map((item, itemIndex) => ( + + + {block.ordered ? `${block.start + itemIndex}.` : '•'} + + + {inline(item, `${itemIndex}:`)} + {streaming && index === last && itemIndex === block.items.length - 1 ? caret : null} + + + ))} + + ); + } + }; + + return ( + + {blocks.length === 0 && streaming ? {caret} : blocks.map((block, index) => {render(block, index)})} + + ); +} + +const styles = StyleSheet.create({ + root: { gap: 9 }, + paragraph: { fontSize: 17, lineHeight: 25 }, + h2: { fontSize: 19, lineHeight: 25 }, + h3: { fontSize: 17 }, + italic: { fontStyle: 'italic' }, + inlineCode: { fontFamily: 'Courier', fontSize: 15 }, + quote: { borderLeftWidth: 3, paddingLeft: 12 }, + codeBlock: { borderRadius: 10, padding: 12 }, + code: { fontFamily: 'Courier', fontSize: 14, lineHeight: 20 }, + rule: { height: 1, marginVertical: 4 }, + list: { gap: 6 }, + item: { flexDirection: 'row', gap: 8 }, + marker: { minWidth: 18 }, + itemText: { flex: 1 }, + caret: { width: 9, height: 18, borderRadius: 2, marginLeft: 3, transform: [{ translateY: 3 }] }, +}); diff --git a/apps/mobile/src/components/placeholder-screen.tsx b/apps/mobile/src/components/placeholder-screen.tsx index a47875eb2..41e3648b5 100644 --- a/apps/mobile/src/components/placeholder-screen.tsx +++ b/apps/mobile/src/components/placeholder-screen.tsx @@ -2,21 +2,22 @@ import { useTranslation } from 'react-i18next'; import { StyleSheet, View } from 'react-native'; import { Card, IconTile } from './card'; +import type { IconName } from './icon-paths'; import { ScreenTitle } from './screen-title'; import { Screen } from './screen'; import { TABS } from './tabs'; import { Text } from './text'; -/** An honest stand-in for a tab whose screens have not reached the app yet. */ -export function PlaceholderScreen({ tab }: { tab: 'prepare' | 'practice' | 'library' }) { +/** An honest stand-in for a tab, or a tool in it, whose screens have not reached the app yet. */ +export function PlaceholderScreen({ tab, title, icon }: { tab: 'prepare' | 'practice'; title?: string; icon?: IconName }) { const { t } = useTranslation(); - const { icon, label } = TABS.find((entry) => entry.name === `(${tab})`)!; + const entry = TABS.find((item) => item.name === `(${tab})`)!; return ( - + - + {t('mobile.placeholder.title')} diff --git a/apps/mobile/src/components/practice-tools.ts b/apps/mobile/src/components/practice-tools.ts new file mode 100644 index 000000000..64691553a --- /dev/null +++ b/apps/mobile/src/components/practice-tools.ts @@ -0,0 +1,12 @@ +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. */ +export const PRACTICE_TOOLS = [ + { key: 'attack', icon: 'shield' }, + { key: 'ask', icon: 'chat' }, + { key: 'crossfire', icon: 'help' }, + { key: 'sparring', icon: 'swords' }, + { key: 'timer', icon: 'timer' }, +] as const satisfies readonly { key: string; icon: IconName }[]; + +export type PracticeTool = (typeof PRACTICE_TOOLS)[number]['key']; diff --git a/apps/mobile/src/lib/ask-config.ts b/apps/mobile/src/lib/ask-config.ts new file mode 100644 index 000000000..9395dd03a --- /dev/null +++ b/apps/mobile/src/lib/ask-config.ts @@ -0,0 +1,94 @@ +import { getDefaultFeatureConfigs } from '@debatelab/core/ai/feature-configs'; +import { canAccessProModels } from '@debatelab/core/ai/pro-access'; +import { + AUTO_FAST, + AUTO_PRO, + DEFAULT_MODEL, + DEFAULT_PRO_MODEL, + getModelCatalogEntry, + isAutoModelSelection, + isReasoningEffort, + type ReasoningEffort, +} from '@debatelab/core/contract/model-catalog'; + +import { supabase } from './supabase'; + +/** + * The Ask 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 { + model: string; + temperature: number; + reasoningEffort: ReasoningEffort; +} + +function defaultConfig(model: string = DEFAULT_MODEL): AskConfig { + const { temperature, reasoningEffort } = getDefaultFeatureConfigs(model).ask; + return { model, temperature, reasoningEffort: reasoningEffort ?? 'low' }; +} + +const LEGACY_MODEL_SUCCESSORS: Record = { + 'gpt-5.5': DEFAULT_MODEL, + 'gpt-5.4': DEFAULT_MODEL, + 'gpt-5.4-mini': DEFAULT_MODEL, + 'gpt-4o': DEFAULT_MODEL, + 'gpt-4o-mini': DEFAULT_MODEL, + 'claude-opus-4-7': DEFAULT_PRO_MODEL, + 'claude-sonnet-4-6': DEFAULT_PRO_MODEL, + 'claude-haiku-4-5-20251001': DEFAULT_MODEL, + 'gemini-3.1-pro-preview': 'google/gemini-3.8-flash', + 'gemini-3.5-flash': 'google/gemini-3.8-flash', + 'gemini-3-flash-preview': 'google/gemini-3.8-flash', + 'gemini-3.1-flash-lite-preview': 'google/gemini-3.8-flash', +}; + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null; +} + +function normalizeModelId(value: unknown, fallback: string): string { + if (typeof value !== 'string' || !value.trim()) return fallback; + const model = value.trim(); + if (isAutoModelSelection(model)) return model; + if (getModelCatalogEntry(model)) return model; + return LEGACY_MODEL_SUCCESSORS[model] ?? DEFAULT_MODEL; +} + +function normalizeConfig(value: unknown, fallback: AskConfig, allowProModels: boolean): AskConfig { + const candidate = asRecord(value); + const model = normalizeModelId(candidate?.model, fallback.model); + return { + model: !allowProModels && model === AUTO_PRO + ? AUTO_FAST + : !allowProModels && getModelCatalogEntry(model)?.isProOnly + ? DEFAULT_MODEL + : model, + temperature: typeof candidate?.temperature === 'number' && Number.isFinite(candidate.temperature) + ? candidate.temperature + : fallback.temperature, + reasoningEffort: isReasoningEffort(candidate?.reasoningEffort) ? candidate.reasoningEffort : fallback.reasoningEffort, + }; +} + +export async function fetchAskConfig(userId: string): 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(); + // 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 + .from('subscriptions') + .select('status, current_period_end') + .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); +} diff --git a/apps/mobile/src/lib/ask-context.ts b/apps/mobile/src/lib/ask-context.ts new file mode 100644 index 000000000..7f7d85af3 --- /dev/null +++ b/apps/mobile/src/lib/ask-context.ts @@ -0,0 +1,120 @@ +import { + boundOutputDigests, + digestOutput, + isDebateContextOutputKind, + MAX_CONTEXT_OUTPUTS, + MAX_CONTEXT_SOURCES, + selectDefaultOutputs, + type DebateContextOutputKind, + type DebateContextRequest, + type DebateContextSide, + type DebateOutputCandidate, + type DebateOutputRef, +} from '@debatelab/core/contract/debate-context'; + +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; + * if they cannot be read, the automatic selection is used. The server loads + * and checks the text. + */ + +const RESULT_TABLES = { + case: 'cases', + attack: 'attacks', + brainstorm: 'brainstorms', + factoids: 'factoids', + chat: 'chats', + crossfire: 'crossfires', +} as const satisfies Partial>; +type ResultKind = keyof typeof RESULT_TABLES; +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; +} + +function rowsOf(result: { data: unknown; error: { message: string } | null }): T[] { + if (result.error) throw new Error(result.error.message); + return (result.data ?? []) as T[]; +} + +/** + * The stored text of one saved result, in the shape digestOutput reads. Like + * every read here it filters on the user, except messages, which have no user + * column and belong to a chat from the user's own list. + */ +async function outputData(userId: string, ref: DebateOutputRef): Promise { + if (ref.kind === 'chat') { + const result = await supabase.from('messages').select('role,content').eq('chat_id', ref.id).order('created_at', { ascending: true }); + return rowsOf(result); + } + if (ref.kind === 'sparring') { + const result = await supabase + .from('sparring_sessions') + .select('user_side,final_evaluation') + .eq('user_id', userId) + .eq('id', ref.id) + .maybeSingle(); + if (result.error) throw new Error(result.error.message); + const row = result.data as { user_side?: unknown; final_evaluation?: unknown } | null; + return { userSide: row?.user_side, scorecard: row?.final_evaluation }; + } + const result = await supabase.from(RESULT_TABLES[ref.kind]).select('output').eq('user_id', userId).eq('id', ref.id).maybeSingle(); + if (result.error) throw new Error(result.error.message); + return (result.data as { output?: unknown } | null)?.output; +} + +/** `excludeChatId` is the chat open in Ask: it is already the conversation. */ +export async function buildAskDebateContext(userId: string, debateId: string, excludeChatId: string | 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), + supabase.from('sparring_sessions').select('id,user_side,updated_at').eq('user_id', userId).eq('debate_id', debateId), + ...RESULT_KINDS.map((kind) => + supabase.from(RESULT_TABLES[kind]).select('id,side,created_at').eq('user_id', userId).eq('debate_id', debateId)), + ]); + + const sourceIds = rowsOf<{ id: string; is_active: boolean }>(sources) + .filter((source) => source.is_active && UUID.test(source.id)) + .slice(0, MAX_CONTEXT_SOURCES) + .map((source) => source.id); + + // Pins are optional; the website falls back to the automatic selection when they cannot be read. + const pinnedKeys = new Set( + pins.error ? [] : ((pins.data ?? []) as { item_kind: unknown; item_id: unknown }[]).flatMap((row) => + isDebateContextOutputKind(row.item_kind) && typeof row.item_id === 'string' ? [`${row.item_kind}:${row.item_id}`] : []), + ); + + const candidates: DebateOutputCandidate[] = []; + RESULT_KINDS.forEach((kind, index) => { + for (const row of rowsOf<{ id: string; side: unknown; created_at: string }>(results[index])) { + if (!UUID.test(row.id) || (kind === 'chat' && row.id === excludeChatId)) continue; + candidates.push({ kind, id: row.id, side: sideOf(row.side), createdAt: new Date(row.created_at).getTime(), pinned: pinnedKeys.has(`${kind}:${row.id}`) }); + } + }); + for (const row of rowsOf<{ id: string; user_side: unknown; updated_at: string }>(sparring)) { + if (!UUID.test(row.id)) continue; + 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); + // 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, + sourceIds, + outputs: sent.filter((ref) => !empty.has(`${ref.kind}:${ref.id}`)), + }; +} diff --git a/apps/mobile/src/lib/ask.ts b/apps/mobile/src/lib/ask.ts new file mode 100644 index 000000000..d034e4d14 --- /dev/null +++ b/apps/mobile/src/lib/ask.ts @@ -0,0 +1,283 @@ +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'; + +/** + * Ask requests, sent the way the website sends them (src/lib/ai/streaming.ts + * and askChat in src/lib/ai/client.ts). Plain questions stream from the + * ai-stream Edge Function. ai-stream refuses Citations, Web search, Pros/Cons + * and Argument map, so with any of them on the answer comes whole from the ai + * 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; + webSearch: boolean; + prosConsMode: boolean; + argumentMapMode: boolean; +} + +export interface HistoryMessage { + role: 'user' | 'assistant'; + content: string; +} + +export interface AskRequest { + message: string; + history: HistoryMessage[]; + options: AskOptions; + config: AskConfig; + 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; +} + +/** Streaming serves plain questions only, as on the website (isAskStreamingSupported). */ +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; + let end = 0; + for (const char of value) { + const size = utf8Length(char); + if (bytes + size > maxBytes) break; + bytes += size; + end += char.length; + } + return value.slice(0, end); +} + +export function isMessageWithinBudget(message: string): boolean { + return utf8Length(message) <= MAX_MESSAGE_BYTES; +} + +/** The last eight messages within the byte budget, as the website's boundAskHistory. */ +export function boundHistory(history: HistoryMessage[]): HistoryMessage[] { + let remaining = MAX_HISTORY_TOTAL_BYTES; + return history.slice(-MAX_HISTORY_ITEMS).reverse().flatMap((message) => { + if (remaining <= 0) return []; + const content = truncateUtf8(message.content, Math.min(MAX_HISTORY_CONTENT_BYTES, remaining)); + remaining -= utf8Length(content); + return content ? [{ ...message, content }] : []; + }).reverse(); +} + +function requestInput(request: AskRequest) { + if (!isMessageWithinBudget(request.message)) throw new AskError('tooLarge', 'Message is too large'); + return { + message: request.message, + history: boundHistory(request.history), + mode: 'general', + citationMode: request.options.citationMode, + webSearch: request.options.webSearch, + prosConsMode: request.options.prosConsMode, + argumentMapMode: request.options.argumentMapMode, + language: request.language, + }; +} + +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'); + 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 { + if (signal?.aborted) throw abortError(); + const token = await accessToken(); + const json = JSON.stringify(body); + if (utf8Length(json) > MAX_REQUEST_BODY_BYTES) throw new AskError('tooLarge', 'Request is too large'); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + const abortForCaller = () => controller.abort(); + signal?.addEventListener('abort', abortForCaller); + try { + 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 = { + feature: 'ask', + input: requestInput(request), + ...(request.debateContext ? { debateContext: request.debateContext } : {}), + provider: PROVIDER, + model: request.config.model, + 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'); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let resolvedModel = request.config.model; + let output: AskOutput | null = null; + + const consume = (events: ParsedSSEEvent[]) => { + for (const event of events) { + const data = event.data && typeof event.data === 'object' ? (event.data as Record) : null; + if (event.event === 'delta' && data && 'text' in data) { + const text = String(data.text ?? ''); + if (text) handlers.onDelta(text); + } + if (event.event === 'final' && data && 'output' in data && !output) { + const meta = data.meta && typeof data.meta === 'object' ? (data.meta as Record) : undefined; + if (typeof meta?.resolvedModel === 'string') resolvedModel = meta.resolvedModel; + output = parseOutput(data.output, resolvedModel); + } + if (event.event === 'error') { + throw errorFrom(typeof data?.status === 'number' ? data.status : undefined, data, 'Streaming request failed'); + } + } + }; + + const cancel = () => void reader.cancel().catch((): undefined => undefined); + handlers.signal?.addEventListener('abort', cancel); + try { + while (true) { + const { done, value } = await reader.read(); + if (handlers.signal?.aborted) throw abortError(); + if (done) break; + const parsed = consumeSSEChunk(buffer, decoder.decode(value, { stream: true })); + buffer = parsed.buffer; + consume(parsed.events); + } + consume(consumeSSEChunk('', `${buffer}\n\n`).events); + if (!output) throw new AskError('failed', 'Streaming request ended before a final response was received'); + return output; + } finally { + handlers.signal?.removeEventListener('abort', cancel); + cancel(); + } + }); +} + +/** Asks the ai Edge Function for a whole answer, with the options the stream cannot serve. */ +export function askOnce(request: AskRequest, signal?: AbortSignal): Promise { + const body = { + feature: 'ask', + input: requestInput(request), + provider: PROVIDER, + model: request.config.model, + temperature: request.config.temperature, + reasoningEffort: request.config.reasoningEffort, + // Servers from before Current Debate context read only this field. + sourceIds: request.debateContext?.sourceIds ?? [], + ...(request.debateContext ? { debateContext: request.debateContext } : {}), + }; + return post('ai', body, signal, async (response) => { + 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); + }); +} diff --git a/apps/mobile/src/lib/chats.ts b/apps/mobile/src/lib/chats.ts new file mode 100644 index 000000000..512231f36 --- /dev/null +++ b/apps/mobile/src/lib/chats.ts @@ -0,0 +1,114 @@ +import type { AskOutput } from '@debatelab/core/ai/schemas/ask'; + +import { supabase } from './supabase'; + +/** + * Ask conversations in the website's chats and messages tables, written the + * way its session store records them (syncAskChatNow in + * src/lib/stores/session-store.ts): a new chat needs a debate, a loaded chat + * keeps the debate it lives in, and only finished exchanges are saved. + */ + +export interface ChatMessage { + id: string; + role: 'user' | 'assistant'; + content: string; + timestamp: number; + output?: AskOutput & Record; +} + +export interface ChatSummary { + id: string; + title: string; + createdAt: number; + messageCount: number; +} + +/** Where the open conversation is saved. `persisted` counts the messages already written. */ +export interface ChatRecord { + chatId: string | null; + debateId: string | null; + persisted: number; +} + +interface MessageRow { + id: string; + role: 'user' | 'assistant'; + content: string; + output: unknown; + created_at: string; +} + +/** + * Every chat 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. + * Messages have no user column and are reached through their chat. + */ + +/** Chats in a debate, or the unsorted ones in one-off mode, newest first, as the website's saved chats list. */ +export async function fetchChats(userId: string, debateId: string | null): Promise { + const query = supabase + .from('chats') + .select('id,title,created_at,messages(count)') + .eq('user_id', userId) + .order('created_at', { ascending: false }); + const { data, error } = await (debateId ? query.eq('debate_id', debateId) : query.is('debate_id', null)); + if (error) throw new Error(error.message); + return ((data ?? []) as { id: string; title: string; created_at: string; messages: { count: number }[] }[]).map((row) => ({ + id: row.id, + title: row.title, + createdAt: new Date(row.created_at).getTime(), + messageCount: row.messages[0]?.count ?? 0, + })); +} + +export async function fetchChat(userId: string, chatId: string): Promise<{ debateId: string | null; messages: ChatMessage[] }> { + const [chat, messages] = await Promise.all([ + supabase.from('chats').select('debate_id').eq('user_id', userId).eq('id', chatId).single(), + supabase.from('messages').select('id,role,content,output,created_at').eq('chat_id', chatId).order('created_at', { ascending: true }), + ]); + if (chat.error) throw new Error(chat.error.message); + if (messages.error) throw new Error(messages.error.message); + return { + debateId: (chat.data as { debate_id: string | null }).debate_id ?? null, + messages: ((messages.data ?? []) as MessageRow[]).map((row) => ({ + id: row.id, + role: row.role, + content: row.content, + timestamp: new Date(row.created_at).getTime(), + output: row.output && typeof row.output === 'object' ? (row.output as ChatMessage['output']) : undefined, + })), + }; +} + +/** + * Writes the messages not saved yet, creating the chat first when needed. + * Returns the record to keep, or null when there is nowhere to save: a new + * conversation in one-off mode stays unsaved, as on the website. + */ +export async function saveChat(userId: string, record: ChatRecord, messages: ChatMessage[], activeDebateId: string | null): Promise { + if (messages.length === 0 || record.persisted >= messages.length) return record; + let { chatId, debateId } = record; + if (!chatId) { + if (!activeDebateId) return null; + const title = messages.find((message) => message.role === 'user')?.content.slice(0, 60) || 'Chat'; + const { data, error } = await supabase + .from('chats') + .insert({ user_id: userId, debate_id: activeDebateId, side: 'neutral', title }) + .select('id') + .single(); + if (error || !data) throw new Error(error?.message ?? 'Could not save the chat'); + chatId = (data as { id: string }).id; + debateId = activeDebateId; + } + const pending = messages.slice(record.persisted); + const { error } = await supabase.from('messages').insert(pending.map((message) => ({ + chat_id: chatId, + role: message.role, + content: message.content, + output: message.output || null, + created_at: new Date(message.timestamp).toISOString(), + }))); + if (error) throw new Error(error.message); + return { chatId, debateId, persisted: messages.length }; +} diff --git a/apps/mobile/src/lib/markdown.test.ts b/apps/mobile/src/lib/markdown.test.ts new file mode 100644 index 000000000..dfce29127 --- /dev/null +++ b/apps/mobile/src/lib/markdown.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { attachCitations, parseInline, parseMarkdown } from './markdown'; + +describe('parseMarkdown', () => { + it('reads the coach answer shape: lead paragraph, bold lead-ins in a list, citation markers', () => { + const blocks = parseMarkdown('Concede the edge case.\n\n1. **Exemptions exist.** Real bans permit carers. [1]\n2. **Flip it.** Same nurse.\n\n- one\n continued'); + expect(blocks).toEqual([ + { type: 'paragraph', inline: [{ type: 'text', text: 'Concede the edge case.' }] }, + { + type: 'list', ordered: true, start: 1, items: [ + [{ type: 'bold', children: [{ type: 'text', text: 'Exemptions exist.' }] }, { type: 'text', text: ' Real bans permit carers. ' }, { type: 'cite', n: 1 }], + [{ type: 'bold', children: [{ type: 'text', text: 'Flip it.' }] }, { type: 'text', text: ' Same nurse.' }], + ], + }, + { type: 'list', ordered: false, start: 1, items: [[{ type: 'text', text: 'one continued' }]] }, + ]); + }); + + it('renders an unfinished bold run as bold while the answer streams', () => { + expect(parseInline('Night **buses follow')).toEqual([ + { type: 'text', text: 'Night ' }, + { type: 'bold', children: [{ type: 'text', text: 'buses follow' }] }, + ]); + }); + + it('keeps an unfinished code fence and does not treat snake_case or 2 * 3 as italics', () => { + expect(parseMarkdown('```\nconst a = 1')).toEqual([{ type: 'code', text: 'const a = 1' }]); + expect(parseInline('use snake_case_names and 2 * 3 * 4')).toEqual([{ type: 'text', text: 'use snake_case_names and 2 * 3 * 4' }]); + }); + + it('only links http(s) URLs', () => { + expect(parseInline('[site](https://example.org/a) [x](javascript:alert(1))')).toEqual([ + { type: 'link', text: 'site', url: 'https://example.org/a' }, + { type: 'text', text: ' [x](javascript:alert(1))' }, + ]); + }); +}); + +describe('attachCitations', () => { + it('puts a citation after the block that quotes its claim, once, and skips claims it cannot find', () => { + const blocks = parseMarkdown('Every real ban permits shift workers and carers.\n\nNothing else here.'); + const cited = attachCitations(blocks, ['Every real ban permits shift workers and carers', 'A claim the answer never makes at all']); + expect(cited[0]).toEqual({ type: 'paragraph', inline: [{ type: 'text', text: 'Every real ban permits shift workers and carers.' }, { type: 'cite', n: 1 }] }); + expect(cited[1]).toEqual(blocks[1]); + expect(blocks[0]).toEqual({ type: 'paragraph', inline: [{ type: 'text', text: 'Every real ban permits shift workers and carers.' }] }); + }); +}); diff --git a/apps/mobile/src/lib/markdown.ts b/apps/mobile/src/lib/markdown.ts new file mode 100644 index 000000000..233681f6c --- /dev/null +++ b/apps/mobile/src/lib/markdown.ts @@ -0,0 +1,214 @@ +/** + * A small Markdown reader for Ask answers. The website renders them with + * react-markdown, which needs the DOM, so the app reads the subset the coach + * writes: paragraphs, headings, lists, quotes, code, bold, italic, links and + * numbered citation markers like [2]. Answers arrive token by token, so an + * unfinished **bold** run renders bold until it closes. + */ + +export type Inline = + | { type: 'text'; text: string } + | { type: 'bold'; children: Inline[] } + | { type: 'italic'; children: Inline[] } + | { type: 'code'; text: string } + | { type: 'link'; text: string; url: string } + | { type: 'cite'; n: number }; + +export type Block = + | { type: 'paragraph'; inline: Inline[] } + | { type: 'heading'; level: number; inline: Inline[] } + | { type: 'list'; ordered: boolean; start: number; items: Inline[][] } + | { type: 'quote'; inline: Inline[] } + | { type: 'code'; text: string } + | { type: 'rule' }; + +const HEADING = /^(#{1,6})\s+(.*)$/; +const RULE = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/; +const BULLET = /^\s*[-*+]\s+(.*)$/; +const ORDERED = /^\s*(\d{1,3})[.)]\s+(.*)$/; +const QUOTE = /^\s{0,3}>\s?(.*)$/; +const FENCE = /^\s{0,3}```/; + +export function parseInline(text: string): Inline[] { + const out: Inline[] = []; + let buffer = ''; + const flush = () => { + if (buffer) out.push({ type: 'text', text: buffer }); + buffer = ''; + }; + let i = 0; + while (i < text.length) { + const rest = text.slice(i); + if (rest.startsWith('`')) { + const end = text.indexOf('`', i + 1); + if (end > i + 1) { + flush(); + out.push({ type: 'code', text: text.slice(i + 1, end) }); + i = end + 1; + continue; + } + } + const strong = rest.startsWith('**') ? '**' : rest.startsWith('__') ? '__' : null; + if (strong) { + const end = text.indexOf(strong, i + 2); + flush(); + // Unclosed while streaming: bold to the end until the closing marker arrives. + const inner = end === -1 ? text.slice(i + 2) : text.slice(i + 2, end); + if (inner) out.push({ type: 'bold', children: parseInline(inner) }); + i = end === -1 ? text.length : end + 2; + continue; + } + const emphasis = rest[0] === '*' || (rest[0] === '_' && !/\w/.test(text[i - 1] ?? '')) ? rest[0] : null; + if (emphasis && rest[1] && rest[1] !== ' ') { + const end = text.indexOf(emphasis, i + 1); + if (end > i + 1 && text[end - 1] !== ' ' && (emphasis === '*' || !/\w/.test(text[end + 1] ?? ''))) { + flush(); + out.push({ type: 'italic', children: parseInline(text.slice(i + 1, end)) }); + i = end + 1; + continue; + } + } + const link = /^\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/.exec(rest); + if (link) { + flush(); + out.push({ type: 'link', text: link[1], url: link[2] }); + i += link[0].length; + continue; + } + const cite = /^\[(\d{1,2})\]/.exec(rest); + if (cite) { + flush(); + out.push({ type: 'cite', n: Number(cite[1]) }); + i += cite[0].length; + continue; + } + buffer += text[i]; + i += 1; + } + flush(); + return out; +} + +export function parseMarkdown(source: string): Block[] { + const lines = source.replace(/\r\n?/g, '\n').split('\n'); + const blocks: Block[] = []; + let paragraph: string[] = []; + let list: { ordered: boolean; start: number; items: string[] } | null = null; + let quote: string[] = []; + + const endParagraph = () => { + if (paragraph.length) blocks.push({ type: 'paragraph', inline: parseInline(paragraph.join(' ')) }); + paragraph = []; + }; + const endList = () => { + if (list) blocks.push({ type: 'list', ordered: list.ordered, start: list.start, items: list.items.map(parseInline) }); + list = null; + }; + const endQuote = () => { + if (quote.length) blocks.push({ type: 'quote', inline: parseInline(quote.join(' ')) }); + quote = []; + }; + const endAll = () => { + endParagraph(); + endList(); + endQuote(); + }; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (FENCE.test(line)) { + endAll(); + const code: string[] = []; + index += 1; + while (index < lines.length && !FENCE.test(lines[index])) { + code.push(lines[index]); + index += 1; + } + blocks.push({ type: 'code', text: code.join('\n') }); + continue; + } + if (!line.trim()) { + endAll(); + continue; + } + const heading = HEADING.exec(line); + if (heading) { + endAll(); + blocks.push({ type: 'heading', level: heading[1].length, inline: parseInline(heading[2].trim()) }); + continue; + } + if (RULE.test(line)) { + endAll(); + blocks.push({ type: 'rule' }); + continue; + } + const bullet = BULLET.exec(line); + const ordered = bullet ? null : ORDERED.exec(line); + if (bullet || ordered) { + endParagraph(); + endQuote(); + const isOrdered = Boolean(ordered); + if (!list || list.ordered !== isOrdered) { + endList(); + list = { ordered: isOrdered, start: ordered ? Number(ordered[1]) : 1, items: [] }; + } + list.items.push((bullet ? bullet[1] : ordered![2]).trim()); + continue; + } + const quoted = QUOTE.exec(line); + if (quoted) { + endParagraph(); + endList(); + quote.push(quoted[1].trim()); + continue; + } + if (list && /^\s+\S/.test(line)) { + // An indented line continues the last list item. + list.items[list.items.length - 1] += ` ${line.trim()}`; + continue; + } + endList(); + endQuote(); + paragraph.push(line.trim()); + } + endAll(); + return blocks; +} + +function plainText(inline: Inline[]): string { + return inline.map((node) => + node.type === 'text' || node.type === 'code' || node.type === 'link' ? node.text + : node.type === 'bold' || node.type === 'italic' ? plainText(node.children) + : '').join(''); +} + +function normalize(text: string): string { + return text.toLocaleLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim(); +} + +function hasCite(inline: Inline[], n: number): boolean { + return inline.some((node) => (node.type === 'cite' && node.n === n) || ((node.type === 'bold' || node.type === 'italic') && hasCite(node.children, n))); +} + +/** + * Places citation n after the paragraph or list item that contains its claim, + * when the answer quotes the claim. Claims the answer only paraphrases keep + * their chip in the row under the answer. + */ +export function attachCitations(blocks: Block[], claims: string[]): Block[] { + const next = blocks.map((block) => (block.type === 'list' ? { ...block, items: block.items.map((item) => [...item]) } : 'inline' in block ? { ...block, inline: [...block.inline] } : block)); + claims.forEach((claim, index) => { + const n = index + 1; + const key = normalize(claim).slice(0, 48); + if (key.length < 16) return; + for (const block of next) { + const targets = block.type === 'list' ? block.items : 'inline' in block ? [block.inline] : []; + const target = targets.find((inline) => normalize(plainText(inline)).includes(key)); + if (target) { + if (!hasCite(target, n)) target.push({ type: 'cite', n }); + return; + } + } + }); + return next; +} diff --git a/src/locales/en.json b/src/locales/en.json index 57f115f00..890e92661 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2472,6 +2472,24 @@ "saveFailed": "Could not save the source. Try again.", "sourceMissing": "This source is no longer in your Library.", "openPage": "Opens the page in the browser" + }, + "ask": { + "placeholder": "Ask your coach", + "openYou": "See your plan", + "openFailed": "Could not open this chat. Try again.", + "historyFailed": "Could not load your chats.", + "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 Ask 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 request took too long. Please try again.", + "tooLarge": "Your question is too long. Shorten it and send it again.", + "debateMissing": "This debate no longer exists. Pick another debate and ask again.", + "failed": "The coach could not answer. Try again." + } } } } diff --git a/src/locales/es.json b/src/locales/es.json index 7ed8a016e..dc4120f84 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2472,6 +2472,24 @@ "saveFailed": "No se pudo guardar la fuente. Inténtalo de nuevo.", "sourceMissing": "Esta fuente ya no está en tu biblioteca.", "openPage": "Abre la página en el navegador" + }, + "ask": { + "placeholder": "Pregunta a tu coach", + "openYou": "Ver tu plan", + "openFailed": "No se pudo abrir este chat. Inténtalo de nuevo.", + "historyFailed": "No se pudieron cargar tus chats.", + "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 Preguntar 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 solicitud tardó demasiado. Inténtalo de nuevo.", + "tooLarge": "Tu pregunta es demasiado larga. Acórtala y envíala de nuevo.", + "debateMissing": "Este debate ya no existe. Elige otro debate y vuelve a preguntar.", + "failed": "El coach no pudo responder. Inténtalo de nuevo." + } } } } diff --git a/src/locales/hu.json b/src/locales/hu.json index a81347912..f10f693ed 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2472,6 +2472,24 @@ "saveFailed": "Nem sikerült menteni a forrást. Próbáld újra.", "sourceMissing": "Ez a forrás már nincs a könyvtáradban.", "openPage": "Megnyitja az oldalt a böngészőben" + }, + "ask": { + "placeholder": "Kérdezd az edződet", + "openYou": "Előfizetés megtekintése", + "openFailed": "Nem sikerült megnyitni ezt a beszélgetést. Próbáld újra.", + "historyFailed": "Nem sikerült betölteni a beszélgetéseidet.", + "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": "A Kérdezéshez 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": "A kérés túl sokáig tartott. Próbáld újra.", + "tooLarge": "Túl hosszú a kérdésed. Rövidítsd le, és küldd el újra.", + "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." + } } } } From 685f4fb2249017d39635f022e334e066fef58d5a Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 08:23:37 +0000 Subject: [PATCH 4/5] Screenshot the Library and Ask screens and document them The screenshot script now captures the Library's plus menu, link and note sheets and a source, and Ask empty, mid-answer and answered with citations. Ask runs on a fake stream inside the page and a fixed answer, so screenshots never call a model or save a chat. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 4 +- apps/mobile/scripts/screenshots.ts | 128 +++++++++++++++++- .../src/app/(tabs)/(library)/library.tsx | 1 + docs/development.md | 2 +- 4 files changed, 131 insertions(+), 4 deletions(-) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 0d4bd83ec..06e080bc9 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -16,9 +16,11 @@ 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; and the form sheets `debates.tsx` (switcher), `new-debate.tsx` and `delete-account.tsx`, 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`, full screen above the tabs. All are declared in the root `_layout.tsx`. - `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. - 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/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 7f6d5d9b9..8a36f209e 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -22,8 +22,14 @@ * the System theme; nothing is written. The Debate tab shows the account's * real Current Debate, and its empty state comes from answering the * current_debates read with no row. The Debate tab is shot once per side view. + * + * Ask screenshots never call a model and never save a chat: the page answers + * ai-stream from a fake stream inside the page (expo/fetch uses the page's + * 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. */ -import { chromium, type Browser, type BrowserContextOptions, type Page } from '@playwright/test'; +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 { createServer, type Server } from 'node:http'; @@ -48,7 +54,7 @@ const DEVICES = { } as const; const SCHEMES = ['light', 'dark'] as const; const LOCALES = { en: 'en-GB', hu: 'hu-HU' } as const; -const SIGNED_IN_ROUTES = { prepare: '/prepare', practice: '/practice', library: '/library', you: '/you' }; +const SIGNED_IN_ROUTES = { prepare: '/prepare', practice: '/practice', you: '/you' }; const REST = `https://${STAGING_HOST}/rest/v1`; const TYPES: Record = { @@ -142,6 +148,117 @@ async function shootDebateScreens(page: Page, prefix: string) { await shoot(page, `${prefix}-new-debate`); } +// A made-up answer in the shape of mock 07, streamed a word at a time. +const ASK_QUESTION = 'How do I answer "what about the nurse who drives in at 5 a.m."?'; +const ASK_STREAM = 'Concede the edge case, then shrink it. Three moves:\n\n1. **Exemptions exist.** Every real ban permits shift workers and carers.\n2. **Night buses follow demand.** Cities that closed their centres added routes in the first year.\n3. **Flip it.** The same nurse breathes the air on her walk from the car park.'; +const ASK_CITED = { + response: 'Two of your sources carry it.\n\n**Exemptions exist.** Every real ban permits shift workers and carers.\n\n**Night buses follow demand.** Cities that closed their centres added routes in the first year.', + citations: [ + { claim: 'Every real ban permits shift workers and carers', source: 'City exemptions policy', verified: true }, + { claim: 'Cities that closed their centres added routes in the first year', source: 'Transit report https://example.org/transit-report', verified: true }, + ], + followUpQuestions: [], + uncertainties: [], +}; +// The stream stops after this many words and stays open, so a shot can catch it mid-answer. +const STREAM_HOLD_WORDS = 34; + +/** Fakes Ask's AI calls and chat writes for this page (see the header). Call before the first navigation. */ +async function fakeAsk(page: Page, hold: boolean) { + await page.addInitScript(({ text, holdAfter }) => { + const realFetch = window.fetch.bind(window); + window.fetch = (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (!url.endsWith('/functions/v1/ai-stream')) return realFetch(input, init); + const words = text.split(/(?<= )/); + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + for (const word of holdAfter ? words.slice(0, holdAfter) : words) { + await new Promise((resolve) => setTimeout(resolve, 15)); + controller.enqueue(encoder.encode(`event: delta\ndata: ${JSON.stringify({ text: word })}\n\n`)); + } + if (holdAfter) return; + const output = { response: text, followUpQuestions: [], uncertainties: [] }; + controller.enqueue(encoder.encode(`event: final\ndata: ${JSON.stringify({ output, meta: {} })}\n\n`)); + controller.close(); + }, + }); + return Promise.resolve(new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } })); + }; + }, { text: ASK_STREAM, holdAfter: hold ? STREAM_HOLD_WORDS : 0 }); + await page.route(`https://${STAGING_HOST}/functions/v1/ai`, (route) => + route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: { output: ASK_CITED } }), + ); + for (const table of ['chats', 'messages']) { + await page.route(`${REST}/${table}*`, (route) => + route.request().method() === 'POST' + ? route.fulfill({ status: 201, json: { id: '00000000-0000-4000-8000-00000000c4a7' } }) + : route.continue(), + ); + } +} + +async function shootLibraryScreens(page: Page, prefix: string) { + await page.goto(`${ORIGIN}/library`); + await page.getByTestId('library-add').waitFor({ timeout: 30_000 }); + await settle(page); + await shoot(page, `${prefix}-library`); + + await page.getByTestId('library-add').click(); + await page.getByRole('menu').waitFor(); + await shoot(page, `${prefix}-library-menu`); + await page.getByRole('menuitem').first().click(); + await page.getByTestId('add-link-sheet').waitFor(); + await page.waitForTimeout(700); + await shoot(page, `${prefix}-add-link`); + + await page.goto(`${ORIGIN}/new-note`); + await page.getByTestId('new-note-sheet').waitFor({ timeout: 30_000 }); + await page.waitForTimeout(700); + await shoot(page, `${prefix}-new-note`); + + await page.goto(`${ORIGIN}/library`); + await page.getByTestId('source-row').first().click({ timeout: 30_000 }); + await page.getByTestId('source-sheet').getByRole('heading').waitFor({ timeout: 30_000 }); + await page.waitForTimeout(700); + await shoot(page, `${prefix}-source`); +} + +async function shootAskScreens(context: BrowserContext, prefix: string, language: string, problems: string[]) { + const ask = async (hold: boolean) => { + const page = await context.newPage(); + watchErrors(page, problems, `${prefix} ask`); + await pinSettings(page, language); + await fakeAsk(page, hold); + await page.goto(`${ORIGIN}/ask`); + await page.getByTestId('ask-input').waitFor({ timeout: 30_000 }); + await settle(page); + return page; + }; + + const empty = await ask(false); + await shoot(empty, `${prefix}-ask-empty`); + await empty.getByTestId('ask-input').fill(ASK_QUESTION); + await empty.getByTestId('ask-send').click(); + await empty.getByTestId('ask-answer').waitFor({ timeout: 15_000 }); + await empty.getByRole('switch').first().click(); + await empty.getByTestId('ask-input').fill('Which of my sources back that up?'); + await empty.getByTestId('ask-send').click(); + await empty.getByTestId('ask-answer').nth(1).waitFor({ timeout: 15_000 }); + await empty.waitForTimeout(300); + await shoot(empty, `${prefix}-ask-answer`); + await empty.close(); + + const streaming = await ask(true); + await streaming.getByTestId('ask-input').fill(ASK_QUESTION); + await streaming.getByTestId('ask-send').click(); + await streaming.getByTestId('ask-streaming').waitFor({ timeout: 15_000 }); + await streaming.waitForTimeout(STREAM_HOLD_WORDS * 15 + 600); + await shoot(streaming, `${prefix}-ask-streaming`); + await streaming.close(); +} + async function shootEmptyDebate(page: Page, prefix: string) { await page.route(`${REST}/current_debates*`, (route) => route.fulfill({ status: 200, contentType: 'application/json', json: [] }), @@ -265,6 +382,11 @@ async function main() { throw error; }); shots += 4; + await shootLibraryScreens(tabPage, prefix).catch(async (error: unknown) => { + await shoot(tabPage, `${prefix}-failed`); + throw error; + }); + shots += 5; for (const [name, route] of Object.entries(SIGNED_IN_ROUTES)) { await tabPage.goto(`${ORIGIN}${route}`); await tabPage.getByRole('tablist').first().waitFor(); @@ -278,6 +400,8 @@ async function main() { await pinSettings(emptyPage, language); await shootEmptyDebate(emptyPage, prefix); shots += 1; + await shootAskScreens(signedIn, prefix, language, problems); + shots += 3; await signedIn.close(); } } diff --git a/apps/mobile/src/app/(tabs)/(library)/library.tsx b/apps/mobile/src/app/(tabs)/(library)/library.tsx index e9ea3cb73..efb12a582 100644 --- a/apps/mobile/src/app/(tabs)/(library)/library.tsx +++ b/apps/mobile/src/app/(tabs)/(library)/library.tsx @@ -228,6 +228,7 @@ function SourceRow({ source, first, onOpen, onDelete }: { source: Source; first: if (event.nativeEvent.actionName === 'delete') void remove(); }} onPress={onOpen} + testID="source-row" style={({ pressed }) => [styles.row, { backgroundColor: colors.card }, pressed && styles.pressed]}> diff --git a/docs/development.md b/docs/development.md index 32c7fd3d0..a1e306018 100644 --- a/docs/development.md +++ b/docs/development.md @@ -108,7 +108,7 @@ npm run -w @debatelab/mobile export:web PORT= npx tsx apps/mobile/scripts/screenshots.ts ``` -Screenshots go to `.verify/mobile-shots/`: sign-in at iPhone and iPad sizes, light and dark, English and Hungarian. With `STAGING_DEMO_CODE` set, the script also signs in to staging, captures the five tabs (the Debate tab in both its In favor and Against views), the debate switcher, the new-debate form and the Debate tab's empty state, and checks that a cold start without a network keeps the user signed in, also after supabase-js's first auth event settles (about 30 seconds offline). Signed in, it answers the app's `user_settings` reads with the run's language and the System theme, because the account's saved choices would otherwise override them; nothing is written. `ONLY=iphone-light-en` limits a run to one combination. Use the seeded Pro code from `~/.config/debatelab/staging-access.env` (see [staging.md](staging.md)), load it inside the command without printing it, and serve on `PORT=18080`: the deployed Edge Functions only accept browser calls from allowed origins such as `http://127.0.0.1:18080`. +Screenshots go to `.verify/mobile-shots/`: sign-in at iPhone and iPad sizes, light and dark, English and Hungarian. With `STAGING_DEMO_CODE` set, the script also signs in to staging, captures the five tabs (the Debate tab in both its In favor and Against views), the debate switcher, the new-debate form, the Debate tab's empty state, the Library's plus menu, link and note sheets and a source, and Ask empty, mid-answer and answered with citations, and checks that a cold start without a network keeps the user signed in, also after supabase-js's first auth event settles (about 30 seconds offline). Signed in, it answers the app's `user_settings` reads with the run's language and the System theme, because the account's saved choices would otherwise override them; nothing is written. Ask runs on a fake stream inside the page and a fixed answer with citations, so screenshots never call a model or save a chat. `ONLY=iphone-light-en` limits a run to one combination. Use the seeded Pro code from `~/.config/debatelab/staging-access.env` (see [staging.md](staging.md)), load it inside the command without printing it, and serve on `PORT=18080`: the deployed Edge Functions only accept browser calls from allowed origins such as `http://127.0.0.1:18080`. ### Expo Go on Adam's iPhone From 41a0b68c35ea716f83009fa358ec9152cac229e4 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 11:56:22 +0000 Subject: [PATCH 5/5] Stop cancelled Ask requests and keep a half-saved chat - Ask listens for cancellation before it reads the session, so leaving Ask during a token refresh no longer sends the question. - When a new chat is created but its messages fail to save, Ask keeps the chat, so the next save adds to it instead of creating a second chat. - A failed link import shows only translated text and logs the raw error. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/add-link.tsx | 3 ++- apps/mobile/src/app/ask.tsx | 6 ++++-- apps/mobile/src/lib/ask.test.ts | 28 ++++++++++++++++++++++++++++ apps/mobile/src/lib/ask.ts | 12 ++++++++---- apps/mobile/src/lib/chats.ts | 11 ++++++++++- 5 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 apps/mobile/src/lib/ask.test.ts diff --git a/apps/mobile/src/app/add-link.tsx b/apps/mobile/src/app/add-link.tsx index dd2c719c2..37ad6a44b 100644 --- a/apps/mobile/src/app/add-link.tsx +++ b/apps/mobile/src/app/add-link.tsx @@ -45,7 +45,8 @@ export default function AddLinkSheet() { setDraft(imported); setTitle(imported.title); } catch (cause) { - setError(`${t('libraryPage.importFailed')}: ${cause instanceof Error ? cause.message : t('libraryPage.importFailedDesc')}`); + console.warn('Could not import the source:', cause instanceof Error ? cause.message : cause); + setError(`${t('libraryPage.importFailed')}. ${t('libraryPage.importFailedDesc')}`); } finally { setBusy(null); } diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx index e43a53b63..3ba8333a9 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -26,7 +26,7 @@ import { import { fetchAskConfig, type AskConfig } from '@/lib/ask-config'; import { buildAskDebateContext } from '@/lib/ask-context'; import { useAuth } from '@/lib/auth'; -import { fetchChat, saveChat, type ChatMessage, type ChatRecord } from '@/lib/chats'; +import { ChatSaveError, fetchChat, saveChat, type ChatMessage, type ChatRecord } from '@/lib/chats'; import { useDebates } from '@/lib/debate-context'; import { useSettings } from '@/lib/settings'; import { fetchSources, type Source } from '@/lib/sources'; @@ -161,7 +161,9 @@ export default function AskScreen() { if (next && owner === conversation.current) setRecord(next); } catch (cause) { console.warn('Could not save the chat:', cause instanceof Error ? cause.message : cause); - if (owner === conversation.current) setNotice(t('debate.recordFailed')); + if (owner !== conversation.current) return; + if (cause instanceof ChatSaveError) setRecord(cause.record); + setNotice(t('debate.recordFailed')); } }); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/apps/mobile/src/lib/ask.test.ts b/apps/mobile/src/lib/ask.test.ts new file mode 100644 index 000000000..b232d1acd --- /dev/null +++ b/apps/mobile/src/lib/ask.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest'; + +const session = vi.hoisted(() => ({ resolve: (_token: string) => {} })); +const fetchMock = vi.hoisted(() => vi.fn()); + +vi.mock('./supabase', () => ({ + supabase: { auth: { getSession: () => new Promise((resolve) => (session.resolve = (token) => resolve({ data: { session: { access_token: token } } }))) } }, +})); +vi.mock('expo/fetch', () => ({ fetch: fetchMock })); + +import { askOnce } from './ask'; + +describe('askOnce', () => { + it('never sends the request when Ask is left while the session is still being read', async () => { + const controller = new AbortController(); + const request = askOnce({ + message: 'Why ban homework?', + history: [], + options: { citationMode: false, webSearch: false, prosConsMode: false, argumentMapMode: false }, + config: { model: 'test-model', temperature: 0.3, reasoningEffort: 'low' }, + language: 'en', + }, controller.signal); + controller.abort(); + session.resolve('token'); + await expect(request).rejects.toMatchObject({ name: 'AbortError' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/lib/ask.ts b/apps/mobile/src/lib/ask.ts index d034e4d14..b1174cdc3 100644 --- a/apps/mobile/src/lib/ask.ts +++ b/apps/mobile/src/lib/ask.ts @@ -173,15 +173,19 @@ function parseOutput(output: unknown, resolvedModel?: string): AskOutput { /** 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 { - if (signal?.aborted) throw abortError(); - const token = await accessToken(); - const json = JSON.stringify(body); - if (utf8Length(json) > MAX_REQUEST_BODY_BYTES) throw new AskError('tooLarge', 'Request is too large'); + // 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' }, diff --git a/apps/mobile/src/lib/chats.ts b/apps/mobile/src/lib/chats.ts index 512231f36..7f2d76fbf 100644 --- a/apps/mobile/src/lib/chats.ts +++ b/apps/mobile/src/lib/chats.ts @@ -31,6 +31,14 @@ export interface ChatRecord { persisted: number; } +/** A failed message write, with the record to keep: the chat may have been created before it failed. */ +export class ChatSaveError extends Error { + constructor(message: string, readonly record: ChatRecord) { + super(message); + this.name = 'ChatSaveError'; + } +} + interface MessageRow { id: string; role: 'user' | 'assistant'; @@ -109,6 +117,7 @@ export async function saveChat(userId: string, record: ChatRecord, messages: Cha output: message.output || null, created_at: new Date(message.timestamp).toISOString(), }))); - if (error) throw new Error(error.message); + // The chat exists now; keep it so the next save adds these messages to it instead of creating another. + if (error) throw new ChatSaveError(error.message, { chatId, debateId, persisted: record.persisted }); return { chatId, debateId, persisted: messages.length }; }