Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Tutor + Librarian agents — mobile UI (AI-Agent-2/3) — mobile (2026-06-26)

Mobile (Expo/React Native) parity for the two agent UIs shipped on web. **Tutor "Smart session"** — entry on the Vocabulary tab → `/tutor` screen: plan view (rationale + per-item exercise badge/difficulty/`why` + reading nudge), study via the existing mobile `FlashCard` rendered straight from the **enriched** plan item (no vocab re-fetch), HITL feedback re-plan → summary. **Librarian "Ask the librarian"** — entry on the Search tab → `/librarian` screen: NL query → reasoning + ranked cards, `library` recs tap through to `/book/[slug]`, `open_library` recs are dashed "Suggestion — not in your library yet" cards with **no navigation**, `usedExternal` note. Both faithfully port the web hardening: mounted-guard + `AbortController` (no setState after screen pop), feedback-failure retry re-submits the **same** session, `MAX_ROUNDS=8` client cap, clamped (`numberOfLines`) untrusted LLM text, unknown-`exerciseType` fallback. Implementation: `apps/mobile/src/lib/agents.ts` (DTO types + `authFetch` API + pure helpers), `useTutorSession`/`useLibrarian` hooks, `TutorPlanView` + `librarian/*` components, screens `app/tutor.tsx` + `app/librarian.tsx`; strings via shared `t()` (keys added to `packages/shared/src/i18n/en.json`), all colors via `useTheme()` (dark-mode correct), haptics/TTS wired. No new network processor (first-party `/me/tutor/*` + `/me/librarian` only — Play Data Safety unaffected). `npx tsc --noEmit` clean; **52 mobile vitest** green (14 new pure-helper tests). Adversarial QA: **0 blockers** — the two web→mobile regression risks (book route exists + correct; external never navigable) both confirmed. **On-device verification** owed (Expo Go can't run all native modules): nav to book + back-stack, Android keyboard-avoid on the librarian input, haptics/TTS on hardware, dark mode. **Deferred**: SSE streaming, external-book ingest, admin replay, tablet layout. This brings both shipped agents to full web+mobile parity.

### Librarian Agent — web UI (Ask the librarian) (AI-Agent-3) — web (2026-06-24)

The frontend for the Librarian agent (backend shipped earlier): a **"Ask the librarian"** natural-language book-discovery surface at `/:lang/discover` (entry from the Discover menu). Type a request — *"books like 1984 about surveillance, under 300 pages"* — → `POST /me/librarian` → the agent's **`reasoning`** + ranked recommendation cards, each with title/authors and the per-item, request-grounded **`why`**. **Library** results reuse the catalog book-card and link to `/:lang/books/{slug}`; **external** (`open_library`) results render as clearly-marked "**Suggestion — not in your library yet**" cards with **no in-app navigation** (the backend is recommend-only — an external suggestion must never masquerade as a real catalog book). A subtle `usedExternal` note shows when the librarian reached beyond the catalog. New `DiscoverPage`, `useLibrarian` state machine (`idle→asking→results|empty|error`), `LibrarianResults`/`LibrarianRecommendationCard`, `librarian.ts` client (`isValidLibrarianQuery` mirrors the backend ≥2/≤500 guard). Reuses the Tutor-UI hardening: mounted-guard + `AbortController` (no setState-after-unmount), `retry()` re-runs the same query, untrusted LLM strings (`reasoning`/`why`) line-clamped (`overflow-wrap:anywhere`) + React-escaped. Auth-gated (signed-out → existing auth modal CTA, stays on the page); SEO-noindexed (`/me/` surface). Adversarial QA: **0 blockers/should-fixes** — the two real risks (a library item with a null `slug` → no broken `/books/undefined` link; external item → never a link) are both guarded and test-covered. `tsc` clean; **595 web tests** green (11 new); `vite build` green; browser-checked (query → reasoning + library link + marked external suggestion + usedExternal + empty/error/min-query/signed-out, **0 console errors**). **Deferred**: mobile Librarian UI, SSE streaming, "add external book to my library"/ingest, pagination. Surfaces a shipped agent that previously had no UI.
Expand Down
37 changes: 37 additions & 0 deletions apps/mobile/app/(tabs)/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,30 @@ export default function DiscoverScreen() {
</View>
</View>

{/* Ask the librarian — natural-language, reasoned recommendations (signed-in only; the screen gates). */}
{!searched && (
<TouchableOpacity
style={[styles.librarianEntry, { backgroundColor: colors.surface, borderColor: colors.border }]}
onPress={() => router.push('/librarian')}
activeOpacity={0.85}
accessibilityRole="button"
accessibilityLabel={t('librarian.title')}
>
<View style={[styles.librarianIcon, { backgroundColor: colors.primaryLight }]}>
<Ionicons name="sparkles-outline" size={18} color={colors.primary} />
</View>
<View style={{ flex: 1 }}>
<Text style={[styles.librarianTitle, { color: colors.text, fontFamily: fonts.sansMedium }]}>
{t('librarian.title')}
</Text>
<Text style={[styles.librarianSubtitle, { color: colors.textSecondary, fontFamily: fonts.sans }]} numberOfLines={1}>
{t('librarian.entry.hint')}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textSecondary} />
</TouchableOpacity>
)}

{loading ? (
<View style={styles.skeletonList}>
{[0, 1, 2, 3].map(i => (
Expand Down Expand Up @@ -451,6 +475,19 @@ const styles = StyleSheet.create({
container: { flex: 1 },
center: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12 },
searchBar: { padding: 12 },
librarianEntry: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
marginHorizontal: 12,
marginBottom: 8,
padding: 12,
borderRadius: 12,
borderWidth: 1,
},
librarianIcon: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' },
librarianTitle: { fontSize: 15 },
librarianSubtitle: { fontSize: 12, marginTop: 2 },
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
Expand Down
20 changes: 16 additions & 4 deletions apps/mobile/app/(tabs)/vocabulary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -297,17 +297,29 @@ export default function VocabularyScreen() {
</PressableScale>
))}
</View>
{dueCount > 0 && (
<View style={styles.reviewRow}>
<View style={styles.reviewRow}>
{dueCount > 0 && (
<TouchableOpacity
style={[styles.reviewBtn, { backgroundColor: colors.primary, flex: 1 }]}
onPress={() => router.push(`/vocabulary/review?reviewMode=${reviewMode}`)}
>
<Ionicons name="school-outline" size={18} color="#fff" style={{ marginRight: 6 }} />
<Text style={[styles.reviewBtnText, { fontFamily: fonts.sansMedium }]}>Practice ({dueCount})</Text>
</TouchableOpacity>
</View>
)}
)}
{/* Smart session — AI tutor plans what to study and explains why (signed-in only; vocab tab is) */}
<TouchableOpacity
style={[styles.reviewBtn, { backgroundColor: colors.surface, borderColor: colors.primary, borderWidth: 1, flex: 1 }]}
onPress={() => router.push('/tutor')}
accessibilityRole="button"
accessibilityLabel={t('tutor.entry.cta')}
>
<Ionicons name="sparkles-outline" size={18} color={colors.primary} style={{ marginRight: 6 }} />
<Text style={[styles.reviewBtnText, { color: colors.primary, fontFamily: fonts.sansMedium }]}>
{t('tutor.entry.cta')}
</Text>
</TouchableOpacity>
</View>
</>
)}

Expand Down
159 changes: 159 additions & 0 deletions apps/mobile/app/librarian.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { useState } from 'react'
import {
View, Text, StyleSheet, SafeAreaView, ScrollView, TextInput,
ActivityIndicator, KeyboardAvoidingView, Platform,
} from 'react-native'
import { Ionicons } from '@expo/vector-icons'
import { useRouter, Stack } from 'expo-router'
import { useTheme } from '../src/context/ThemeContext'
import { useLanguage } from '../src/context/LanguageContext'
import { useAuth } from '../src/context/AuthContext'
import { useLibrarian } from '../src/hooks/useLibrarian'
import { isValidLibrarianQuery, MAX_QUERY_LENGTH } from '../src/lib/agents'
import { fonts } from '../src/theme/typography'
import { EmptyState } from '../src/components/ui/EmptyState'
import { PressableScale } from '../src/components/ui/PressableScale'
import { LibrarianResults } from '../src/components/librarian/LibrarianResults'

// Ask the librarian (Librarian agent, AI-Agent-3). NL request → reasoning + ranked recommendation cards.
// Signed-in only (it's a `/me/` endpoint). `library` cards navigate to the in-app book; `open_library` cards
// are marked external suggestions with no navigation.
export default function LibrarianScreen() {
const { colors } = useTheme()
const { t } = useLanguage()
const { user } = useAuth()
const router = useRouter()
const librarian = useLibrarian()
const [query, setQuery] = useState('')

const canAsk = isValidLibrarianQuery(query) && librarian.phase !== 'asking'
const handleAsk = () => {
if (canAsk) librarian.run(query.trim())
}
const handleOpen = (slug: string) => router.push(`/book/${slug}`)

const screen = <Stack.Screen options={{ title: t('librarian.title'), headerShown: true }} />

// Signed-out
if (!user) {
return (
<>
{screen}
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
<EmptyState
icon="library-outline"
title={t('librarian.signIn.title')}
subtitle={t('librarian.signIn.subtitle')}
buttonLabel={t('librarian.signIn.cta')}
onButtonPress={() => router.replace('/(auth)/login')}
/>
</SafeAreaView>
</>
)
}

return (
<>
{screen}
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
{/* Prompt */}
<View style={styles.promptBox}>
<Text style={[styles.subtitle, { color: colors.textSecondary, fontFamily: fonts.sans }]}>
{t('librarian.subtitle')}
</Text>
<TextInput
style={[styles.input, { backgroundColor: colors.surface, borderColor: colors.border, color: colors.text, fontFamily: fonts.sans }]}
value={query}
onChangeText={setQuery}
placeholder={t('librarian.placeholder')}
placeholderTextColor={colors.textSecondary}
multiline
maxLength={MAX_QUERY_LENGTH}
accessibilityLabel={t('librarian.inputLabel')}
returnKeyType="search"
blurOnSubmit
onSubmitEditing={handleAsk}
/>
<PressableScale
onPress={handleAsk}
disabled={!canAsk}
style={[styles.askBtn, { backgroundColor: canAsk ? colors.primary : colors.border }]}
accessibilityRole="button"
accessibilityLabel={t('librarian.ask')}
>
{librarian.phase === 'asking' ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<>
<Ionicons name="sparkles-outline" size={16} color="#fff" />
<Text style={[styles.askBtnText, { fontFamily: fonts.sansBold }]}>{t('librarian.ask')}</Text>
</>
)}
</PressableScale>
</View>

<ScrollView contentContainerStyle={styles.results} keyboardShouldPersistTaps="handled">
{librarian.phase === 'asking' && (
<View style={styles.center}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.thinking, { color: colors.textSecondary, fontFamily: fonts.sans }]}>
{t('librarian.thinking')}
</Text>
</View>
)}

{librarian.phase === 'empty' && (
<EmptyState
icon="search-outline"
title={t('librarian.empty.title')}
subtitle={t('librarian.empty.subtitle')}
/>
)}

{librarian.phase === 'error' && (
<EmptyState
icon="warning-outline"
title={t('librarian.error.title')}
subtitle={librarian.error || t('librarian.error.subtitle')}
buttonLabel={t('librarian.error.retry')}
onButtonPress={() => librarian.retry()}
/>
)}

{librarian.phase === 'results' && librarian.response && (
<LibrarianResults response={librarian.response} t={t} onOpen={handleOpen} />
)}
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
</>
)
}

const styles = StyleSheet.create({
promptBox: { padding: 16, gap: 10 },
subtitle: { fontSize: 13, lineHeight: 19 },
input: {
minHeight: 90,
borderRadius: 12,
borderWidth: 1,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 15,
lineHeight: 21,
textAlignVertical: 'top',
},
askBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
paddingVertical: 13,
borderRadius: 12,
},
askBtnText: { color: '#fff', fontSize: 16 },
results: { padding: 16, paddingTop: 0, paddingBottom: 32 },
center: { alignItems: 'center', justifyContent: 'center', paddingVertical: 48, gap: 16 },
thinking: { fontSize: 14, textAlign: 'center' },
})
Loading
Loading