diff --git a/apps/mobile/src/components/AskMarkdown.tsx b/apps/mobile/src/components/Markdown.tsx
similarity index 96%
rename from apps/mobile/src/components/AskMarkdown.tsx
rename to apps/mobile/src/components/Markdown.tsx
index d34a27984..83c4e5353 100644
--- a/apps/mobile/src/components/AskMarkdown.tsx
+++ b/apps/mobile/src/components/Markdown.tsx
@@ -5,14 +5,15 @@ import { fonts } from '../theme/typography'
import { isTableSeparator } from '../lib/markdown'
/**
- * Small self-contained markdown renderer for assistant chat answers (tutor-register gpt-4.1 output:
+ * Small self-contained markdown renderer. Its content is what an outside assistant wrote back as a
+ * BookInsight (Markdown on the wire:
* `##` headings, bullets, numbered lists, **bold**, *italic*, `inline code`, fenced code blocks,
* blockquotes, and GFM tables). Deliberately NOT `react-native-markdown-display` — that dep is
* unmaintained against RN 0.83 / the new architecture, and adding a native-adjacent parser is a
* Play Data-Safety + OTA risk for zero upside here. This renderer is ~180 lines, theme-tokenised,
* and pure-JS (OTA-safe). No raw-HTML path exists — model output is only ever tokenised as markdown.
*
- * Divergences vs web AskMarkdown: tables degrade to a horizontally-scrollable monospace block
+ * Divergences vs web Markdown: tables degrade to a horizontally-scrollable monospace block
* (RN has no
); inline `[n]` citation markers render as plain text (the citation CHIPS below
* the answer are the jump surface on mobile). Links render as literal text (the inline pass has no
* link rule) — intended: answers are grounded in the book, external links are vanishingly rare.
@@ -59,7 +60,7 @@ function renderInline(line: string, colors: InlineColors, keyPrefix: string): Re
const HEADING_SIZE: Record = { 1: 22, 2: 19, 3: 17, 4: 15, 5: 14, 6: 13 }
-export const AskMarkdown = memo(function AskMarkdown({ text }: { text: string }) {
+export const Markdown = memo(function Markdown({ text }: { text: string }) {
const { colors } = useTheme()
const inline: InlineColors = { text: colors.text, code: colors.primary, codeBg: colors.border + '66' }
const blocks: ReactNode[] = []
diff --git a/apps/mobile/src/components/library/BookInsightsSection.tsx b/apps/mobile/src/components/library/BookInsightsSection.tsx
index a4cb217e2..2bd424260 100644
--- a/apps/mobile/src/components/library/BookInsightsSection.tsx
+++ b/apps/mobile/src/components/library/BookInsightsSection.tsx
@@ -4,7 +4,7 @@ import { insightsApi, insightChapterLabel, type BookInsight } from '@textstack/s
import { useTheme } from '../../context/ThemeContext'
import { useLanguage } from '../../context/LanguageContext'
import { fonts } from '../../theme/typography'
-import { AskMarkdown } from '../AskMarkdown'
+import { Markdown } from '../Markdown'
/**
* "What you've worked out" — the conclusions an outside assistant wrote back into
@@ -19,7 +19,7 @@ import { AskMarkdown } from '../AskMarkdown'
* a tutorial for a feature you can only reach by connecting an assistant, and it
* would sit on every book screen forever for the readers who never do.
*
- * Markdown goes through `AskMarkdown`, the renderer the Ask sheet already uses:
+ * Markdown goes through the shared `Markdown` renderer:
* pure JS, theme-tokenised, OTA-safe, and no raw-HTML path — so assistant output
* is only ever tokenised, never interpreted.
*/
@@ -71,7 +71,7 @@ export function BookInsightsSection({ userBookId, editionId }: Props) {
) : null}
-
+
))}
diff --git a/apps/mobile/src/components/reader/ReaderShell.tsx b/apps/mobile/src/components/reader/ReaderShell.tsx
index 2ebca20b5..fdd2ea328 100644
--- a/apps/mobile/src/components/reader/ReaderShell.tsx
+++ b/apps/mobile/src/components/reader/ReaderShell.tsx
@@ -3,7 +3,7 @@ import type { MutableRefObject, RefObject } from 'react'
import { View, Text, StyleSheet, TouchableOpacity, Animated, Linking, BackHandler } from 'react-native'
import { WebView } from 'react-native-webview'
import { useRouter, Stack } from 'expo-router'
-import { t, computeBookProgress, estimateTimeLeft, formatMinutesLeft, citationChapterSlug, makeSnippet, plural, resolvePdfResumePage, chapterEndPage } from '@textstack/shared'
+import { t, computeBookProgress, estimateTimeLeft, formatMinutesLeft, plural, resolvePdfResumePage, chapterEndPage } from '@textstack/shared'
import type { Chapter, BookmarkDto, TextPosition } from '@textstack/shared'
import { buildReaderHtml, buildPdfViewerHtml } from '../../lib/readerHtml'
import {
@@ -649,10 +649,6 @@ export function ReaderShell(props: ReaderShellProps) {
onNavigateChapter(slug)
}
- // RAG citation (AI-026d): scroll the WebView to the cited passage.
- const pendingCitationRef = useRef<{ slug: string; snippet: string; charStart: number } | null>(null)
- const scrollToCitation = (snippet: string, charStart: number) =>
- injectJs(`window.__textstackScrollToCitation && window.__textstackScrollToCitation(${JSON.stringify(snippet)}, ${charStart})`)
// M2: scroll the reflow WebView to a saved highlight (no chapter navigation →
// reading position preserved). The Highlights sheet's list is always the
@@ -955,13 +951,6 @@ export function ReaderShell(props: ReaderShellProps) {
// Scroll-restore is owned by useReaderPersistence — it coordinates
// this signal with the async saved-position fetch (no race).
onWebViewLoaded()
- // A cross-chapter citation jump (AI-026d): once the cited chapter has rendered, scroll
- // to the passage — after restore (delay) so the explicit jump wins.
- const pc = pendingCitationRef.current
- if (pc && pc.slug === activeSlug) {
- pendingCitationRef.current = null
- setTimeout(() => scrollToCitation(pc.snippet, pc.charStart), 120)
- }
}}
originWhitelist={['*']}
// Android's WebView ignores the viewport's user-scalable unless the
diff --git a/apps/mobile/src/lib/markdown.ts b/apps/mobile/src/lib/markdown.ts
index 80469a350..2bb4109da 100644
--- a/apps/mobile/src/lib/markdown.ts
+++ b/apps/mobile/src/lib/markdown.ts
@@ -1,4 +1,4 @@
-// Pure markdown-block helpers for the chat renderer (AskMarkdown). Kept RN-free so the block-detection
+// Pure markdown-block helpers for the `Markdown` renderer. Kept RN-free so the block-detection
// rules are unit-testable under Vitest (see markdown.test.ts) without bundling React Native.
/**
diff --git a/apps/mobile/src/lib/sse.ts b/apps/mobile/src/lib/sse.ts
deleted file mode 100644
index a6172e2d4..000000000
--- a/apps/mobile/src/lib/sse.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { fetch as expoFetch } from 'expo/fetch'
-import {
- createSseParser,
- SseUnauthorizedError,
- SseUnsupportedError,
- type SseEvent,
-} from './sseParser'
-
-// SSE-over-POST for mobile. React Native's built-in `fetch` buffers the whole body (no streaming),
-// so we use `expo/fetch` (Expo SDK 52+ WinterCG fetch) which exposes `response.body` as a real
-// ReadableStream. Same POST-with-body pattern the web reader uses (EventSource can't POST) and the
-// same minimal parser (./sseParser) — the wire format is byte-identical to the legacy `/ask` stream.
-//
-// Auth is Bearer (mobile has no cookies): the caller passes the access token; a 401 surfaces as
-// `SseUnauthorizedError` so the caller can refresh + retry (see bookChat.sendChatMessage).
-
-export { SseUnauthorizedError, SseUnsupportedError } from './sseParser'
-
-/**
- * POSTs JSON and consumes the SSE response, invoking `onEvent` per event. Maps non-OK statuses to
- * readable errors BEFORE streaming (rate-limit/unavailable responses are JSON, not SSE). Throws
- * `SseUnsupportedError` when the body can't be streamed so the caller can fall back to a plain POST.
- */
-export async function postSse(
- url: string,
- token: string | null,
- body: unknown,
- onEvent: (e: SseEvent) => void,
- signal?: AbortSignal,
-): Promise {
- const res = await expoFetch(url, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Accept: 'text/event-stream',
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
- },
- body: JSON.stringify(body),
- signal,
- })
-
- if (!res.ok) {
- if (res.status === 401) throw new SseUnauthorizedError()
- if (res.status === 503) throw new Error('Service unavailable')
- if (res.status === 504) throw new Error('Request timed out')
- if (res.status === 429) throw new Error('Too many requests, try again later')
- const text = await res.text().catch(() => '')
- let error = `Request failed: ${res.status}`
- try {
- const json = JSON.parse(text)
- if (json?.detail) error = json.detail
- else if (json?.error) error = json.error
- } catch {
- // non-JSON error body — keep the status message
- }
- throw new Error(error)
- }
-
- if (!res.body) throw new SseUnsupportedError()
-
- const parser = createSseParser(onEvent)
- // Guard the stream setup itself: an OK response whose body isn't a spec ReadableStream (no
- // `getReader`), or a runtime with no `TextDecoder` global, can't be streamed. Rethrow as
- // `SseUnsupportedError` so the caller degrades to `sendChatMessageJson` instead of dead-ending
- // on a raw TypeError banner. (A failure DURING the read below is a genuine stream error, not this.)
- let reader: ReadableStreamDefaultReader
- let decoder: TextDecoder
- try {
- reader = res.body.getReader()
- decoder = new TextDecoder()
- } catch {
- throw new SseUnsupportedError()
- }
- try {
- while (true) {
- const { done, value } = await reader.read()
- if (done) break
- if (value) parser.feed(decoder.decode(value, { stream: true }))
- }
- parser.feed(decoder.decode()) // flush any trailing multi-byte sequence
- parser.end()
- } finally {
- reader.releaseLock()
- }
-}
diff --git a/apps/mobile/src/lib/sseParser.test.ts b/apps/mobile/src/lib/sseParser.test.ts
deleted file mode 100644
index 1039b3bba..000000000
--- a/apps/mobile/src/lib/sseParser.test.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import { describe, it, expect, vi } from 'vitest'
-import { createSseParser, parseAskDone, makeAskSseHandler, type SseEvent } from './sseParser'
-
-describe('createSseParser', () => {
- function collect(chunks: string[]): SseEvent[] {
- const events: SseEvent[] = []
- const p = createSseParser(e => events.push(e))
- for (const c of chunks) p.feed(c)
- p.end()
- return events
- }
-
- it('parses a single event split by a blank line', () => {
- expect(collect(['event: delta\ndata: hello\n\n'])).toEqual([{ event: 'delta', data: 'hello' }])
- })
-
- it('joins multi-line data with newlines', () => {
- expect(collect(['data: a\ndata: b\n\n'])).toEqual([{ event: 'message', data: 'a\nb' }])
- })
-
- it('reassembles an event split across chunk boundaries', () => {
- expect(collect(['event: de', 'lta\ndata: wor', 'ld\n\n'])).toEqual([{ event: 'delta', data: 'world' }])
- })
-
- it('is CRLF-tolerant and ignores comment/keep-alive lines', () => {
- expect(collect([': keep-alive\r\nevent: done\r\ndata: {}\r\n\r\n'])).toEqual([{ event: 'done', data: '{}' }])
- })
-
- it('flushes a trailing event on end() with no final blank line', () => {
- expect(collect(['event: done\ndata: {"insufficient":true}'])).toEqual([
- { event: 'done', data: '{"insufficient":true}' },
- ])
- })
-
- it('handles the full delta*+done stream shape', () => {
- const events = collect([
- 'event: delta\ndata: The \n\n',
- 'event: delta\ndata: whale\n\n',
- 'event: done\ndata: {"citations":[],"insufficient":false}\n\n',
- ])
- expect(events).toEqual([
- { event: 'delta', data: 'The ' },
- { event: 'delta', data: 'whale' },
- { event: 'done', data: '{"citations":[],"insufficient":false}' },
- ])
- })
-})
-
-describe('parseAskDone', () => {
- it('defaults every field on malformed JSON', () => {
- expect(parseAskDone('not json')).toEqual({ citations: [], lastReadOrd: 0, insufficient: false })
- })
-
- it('parses citations + insufficient + lastReadOrd', () => {
- const done = parseAskDone('{"citations":[{"marker":1}],"lastReadOrd":4,"insufficient":true}')
- expect(done.insufficient).toBe(true)
- expect(done.lastReadOrd).toBe(4)
- expect(done.citations).toHaveLength(1)
- })
-
- it('coerces a truthy non-boolean insufficient to a boolean', () => {
- expect(parseAskDone('{"insufficient":1}').insufficient).toBe(true)
- })
-})
-
-describe('makeAskSseHandler', () => {
- it('routes delta/done/error to the right callbacks', () => {
- const onDelta = vi.fn(); const onDone = vi.fn(); const onError = vi.fn()
- const h = makeAskSseHandler({ onDelta, onDone, onError })
- h({ event: 'delta', data: 'hi' })
- h({ event: 'done', data: '{"insufficient":true}' })
- h({ event: 'error', data: 'boom' })
- expect(onDelta).toHaveBeenCalledWith('hi')
- expect(onDone).toHaveBeenCalledWith({ citations: [], lastReadOrd: 0, insufficient: true })
- expect(onError).toHaveBeenCalledWith('boom')
- })
-
- it('drops events once the signal is aborted', () => {
- const onDelta = vi.fn()
- const ctrl = new AbortController()
- const h = makeAskSseHandler({ onDelta, onDone: vi.fn(), onError: vi.fn(), signal: ctrl.signal })
- ctrl.abort()
- h({ event: 'delta', data: 'late' })
- expect(onDelta).not.toHaveBeenCalled()
- })
-
- it('falls back to a generic message for an empty error frame', () => {
- const onError = vi.fn()
- makeAskSseHandler({ onDelta: vi.fn(), onDone: vi.fn(), onError })({ event: 'error', data: '' })
- expect(onError).toHaveBeenCalledWith('Ask failed')
- })
-})
diff --git a/apps/mobile/src/lib/sseParser.ts b/apps/mobile/src/lib/sseParser.ts
deleted file mode 100644
index df44a513b..000000000
--- a/apps/mobile/src/lib/sseParser.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-// Pure SSE parsing for the mobile chat/ask streams. Kept RN-free (no expo/fetch import) so it's
-// unit-testable under Vitest (see sseParser.test.ts). The network layer that actually opens the
-// stream lives in ./sse — it imports `createSseParser` from here. Wire format is identical to the
-// web reader's parser (apps/web/src/lib/sse.ts): `event:`/`data:` fields, blank-line dispatch,
-// multi-line data joined with \n, comment lines (`:`) ignored, CRLF-tolerant.
-
-export interface SseEvent {
- event: string
- data: string
-}
-
-/** Terminal `done` payload of a streamed ask (same shape the legacy `/ask` `done` frame carries). */
-export interface AskDone {
- citations: import('@textstack/shared').AskCitation[]
- lastReadOrd: number
- insufficient: boolean
-}
-
-export interface AskStreamCallbacks {
- onDelta: (fragment: string) => void
- onDone: (done: AskDone) => void
- onError: (message: string) => void
- signal?: AbortSignal
-}
-
-/** The SSE request was rejected with 401 — the caller should refresh the token / prompt sign-in. */
-export class SseUnauthorizedError extends Error {
- constructor() {
- super('Unauthorized')
- this.name = 'SseUnauthorizedError'
- }
-}
-
-/** The environment can't stream (no response body reader) — caller falls back to a plain JSON POST. */
-export class SseUnsupportedError extends Error {
- constructor() {
- super('Streaming not supported')
- this.name = 'SseUnsupportedError'
- }
-}
-
-/**
- * Stateful SSE parser: feed it raw text chunks (any split points), it dispatches complete events.
- * Subset of the SSE spec sufficient for our endpoints. Call `end()` to flush a trailing event from
- * a stream that closed without a final blank line.
- */
-export function createSseParser(onEvent: (e: SseEvent) => void) {
- let buffer = ''
- let eventType = 'message'
- let dataLines: string[] = []
-
- const dispatch = () => {
- if (dataLines.length > 0) onEvent({ event: eventType, data: dataLines.join('\n') })
- eventType = 'message'
- dataLines = []
- }
-
- const processLine = (line: string) => {
- if (line === '') return dispatch()
- if (line.startsWith(':')) return // comment / keep-alive
- const colon = line.indexOf(':')
- const field = colon === -1 ? line : line.slice(0, colon)
- // Per spec a single space after the colon is stripped, further spaces are data.
- let value = colon === -1 ? '' : line.slice(colon + 1)
- if (value.startsWith(' ')) value = value.slice(1)
- if (field === 'event') eventType = value
- else if (field === 'data') dataLines.push(value)
- // id/retry/unknown fields ignored
- }
-
- return {
- feed(chunk: string) {
- buffer += chunk
- let nl: number
- while ((nl = buffer.indexOf('\n')) !== -1) {
- let line = buffer.slice(0, nl)
- if (line.endsWith('\r')) line = line.slice(0, -1)
- buffer = buffer.slice(nl + 1)
- processLine(line)
- }
- },
- end() {
- if (buffer.length > 0) processLine(buffer.endsWith('\r') ? buffer.slice(0, -1) : buffer)
- buffer = ''
- dispatch()
- },
- }
-}
-
-/**
- * Parses a `done` frame's JSON data into the terminal payload, defaulting every field so a malformed
- * frame never throws. Mirrors web's `makeAskSseHandler` done-branch.
- */
-export function parseAskDone(data: string): AskDone {
- try {
- const parsed = JSON.parse(data) as Partial
- return {
- citations: parsed.citations ?? [],
- lastReadOrd: parsed.lastReadOrd ?? 0,
- insufficient: Boolean(parsed.insufficient),
- }
- } catch {
- return { citations: [], lastReadOrd: 0, insufficient: false }
- }
-}
-
-/**
- * Builds the per-event handler for an ask-style SSE stream (`delta` → text fragment, `done` →
- * citations/insufficient JSON, `error` → message). Single-sourced so the persistent book-chat parses
- * the identical wire format as the legacy ask. Respects `signal` so aborted streams stop dispatching.
- */
-export function makeAskSseHandler({ onDelta, onDone, onError, signal }: AskStreamCallbacks) {
- return (e: SseEvent) => {
- if (signal?.aborted) return
- if (e.event === 'delta') onDelta(e.data)
- else if (e.event === 'done') onDone(parseAskDone(e.data))
- else if (e.event === 'error') onError(e.data || 'Ask failed')
- }
-}
diff --git a/docs/05-features/assistant-handoff.md b/docs/05-features/assistant-handoff.md
index 607d8bfe2..ea57bea93 100644
--- a/docs/05-features/assistant-handoff.md
+++ b/docs/05-features/assistant-handoff.md
@@ -135,6 +135,23 @@ generated EF snapshots). Backend, web, admin and mobile all build; 1,234 backend
| Mobile `Linking.openURL` | Still swallows the failure |
| Tutor's worked example | `get_example_sentence` went with the retrieval spine. `VocabularyWord.Sentence` already holds the sentence a word was saved from, so this is a rewire with no retrieval — not started |
+### Left behind by the cut — a follow-up, found 2026-09-10 after PR #596 opened
+
+Removing the chat left inert plumbing on the mobile side. None of it is a correctness risk — the
+modules have no importers and the one live-looking block cannot execute — but it is exactly the
+residue this work exists to remove, so it goes in its own small PR rather than riding along:
+
+- **`apps/mobile/src/lib/sse.ts` and `sseParser.ts` (+ its test) have no consumers at all.** Their
+ only caller was `bookChat.ts`. Note the web's `lib/sse.ts` is NOT dead — `useExplain` still streams
+ through it.
+- **`ReaderShell.tsx` still imports `citationChapterSlug` and `makeSnippet`**, and keeps
+ `pendingCitationRef` + `scrollToCitation` alive at :653-654 and :960-963. The only writer of that
+ ref was the deleted `handleCitation`, so the ref is permanently null and the block at :960 can
+ never run.
+- **`packages/shared/src/reader/citation.ts`** exists for that path only.
+- **`AskCitation`, `AskResponse`, `AskTurnDto`, `AskTarget`** in `packages/shared/src/types/api.ts`
+ are now referenced only by the dead `sseParser` and by `citation.ts`'s own doc comment.
+
### Found while cutting — decisions still open
1. ~~**`GET /books/{slug}/similar`**~~ — **decided: deleted.** The rail was fed by
diff --git a/docs/STATUS.md b/docs/STATUS.md
index 249f50a9e..18ebfa054 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -79,6 +79,14 @@ answers "what happened" and nothing answered "what is half-finished right now".
## Known-broken / open follow-ups
+- **Five web modules and one stylesheet have no importers, and did not before this work either.**
+ `lib/fuzzyMatch.ts`, `lib/wordAtPoint.ts`, `hooks/useOfflineDownload.ts`, `hooks/useSwipe.ts`,
+ `hooks/useVocabLevel.ts`, and `styles/native-language-picker.css` (whose `.native-lang-picker`
+ selector appears in no component). Verified dead on `main` as well, so this is pre-existing rather
+ than fallout from the 2026-09-10 cut — recorded here rather than swept in with it. `useSwipe` and
+ `useOfflineDownload` in particular are documented in this file's own hook inventory as if they were
+ live; they are not.
+
Each of these is a real defect that is *known and not yet fixed*. They live here rather than in
someone's memory.
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index d029969d9..95e15c9c3 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -16,7 +16,6 @@ export * from './reader/textAnchor'
export * from './reader/textPosition'
export * from './reader/progressPayload'
export * from './reader/continueReading'
-export * from './reader/citation'
export * from './reader/pdfProgress'
export * from './reader/pdfPageWindow'
export * from './reader/pdfHighlightAnchor'
diff --git a/packages/shared/src/reader/citation.test.ts b/packages/shared/src/reader/citation.test.ts
deleted file mode 100644
index c21c5bdce..000000000
--- a/packages/shared/src/reader/citation.test.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { describe, it, expect } from 'vitest'
-import { citationLabel } from './citation'
-
-const chapters = [
- { chapterNumber: 0, title: 'Book I' },
- { chapterNumber: 1, title: 'Book II' },
-]
-
-describe('citationLabel', () => {
- it('names the chapter rather than its stored ordinal', () => {
- // The defect verbatim: six chips all reading "ch.0" under an answer about Book I.
- expect(citationLabel({ marker: 6, chapterOrd: 0 }, chapters)).toBe('[6] Book I')
- })
-
- it('carries the marker so [n] in the answer can be found below it', () => {
- expect(citationLabel({ marker: 13, chapterOrd: 1 }, chapters)).toBe('[13] Book II')
- })
-
- it('falls back to a 1-based number when the chapter list is not to hand', () => {
- // ch.0 is the first chapter, not a missing value — so the fallback must not print the raw ord.
- expect(citationLabel({ marker: 1, chapterOrd: 0 })).toBe('[1] ch.1')
- expect(citationLabel({ chapterOrd: 3 })).toBe('ch.4')
- })
-
- it('prefers a page for an unanchored PDF citation', () => {
- expect(citationLabel({ marker: 2, chapterOrd: 0, sourcePage: 12 }, chapters)).toBe('[2] p.12')
- })
-
- it('survives a citation with neither chapter nor page', () => {
- expect(citationLabel({ marker: 4 })).toBe('[4]')
- expect(citationLabel({})).toBe('—')
- })
-
- it('ignores a blank chapter title rather than rendering an empty chip', () => {
- expect(citationLabel({ marker: 1, chapterOrd: 0 }, [{ chapterNumber: 0, title: ' ' }]))
- .toBe('[1] ch.1')
- })
-})
diff --git a/packages/shared/src/reader/citation.ts b/packages/shared/src/reader/citation.ts
deleted file mode 100644
index 200ce97f0..000000000
--- a/packages/shared/src/reader/citation.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-/**
- * Resolves a RAG citation's chapter ordinal to the chapter's slug, for navigating the reader to the
- * cited chapter (AI-026). Returns undefined when no chapter matches.
- */
-export function citationChapterSlug(
- chapters: { chapterNumber?: number; slug: string }[],
- chapterOrd: number,
-): string | undefined {
- return chapters.find(c => c.chapterNumber === chapterOrd)?.slug
-}
-
-/** The chapter a citation points at, when the reader's chapter list is available. */
-export function citationChapter(
- chapters: T[],
- chapterOrd: number | null | undefined,
-): T | undefined {
- if (chapterOrd == null) return undefined
- return chapters.find(c => c.chapterNumber === chapterOrd)
-}
-
-/**
- * What a citation chip says.
- *
- * It used to say `ch.0`, six times, under an answer citing `[1][6][7][13][14][17]` — and QA could
- * not match a single marker to a single chip. Two separate faults wearing one symptom:
- *
- * 1. **`chapterOrd` is 0-based.** `ch.0` was not a null leaking through; it is the first chapter,
- * the one titled "Book I". Every other reader surface prints either the title or `ord + 1`
- * (`ReaderTocDrawer`, `ReaderFooterNav`), so the chip was the one place showing the internal
- * index. It prefers the real title now, and falls back to `ch.{ord + 1}` when the chapter list
- * is not to hand.
- * 2. **The marker was thrown away.** `AskCitation.marker` carries the `[n]` from the answer text and
- * mobile never rendered it — and mobile deliberately does not make the inline markers tappable,
- * so the chip is the only place the two could ever have been joined.
- */
-export function citationLabel(
- citation: { marker?: number; chapterOrd?: number | null; sourcePage?: number | null },
- chapters: { chapterNumber?: number; title?: string }[] = [],
-): string {
- const marker = citation.marker != null ? `[${citation.marker}] ` : ''
-
- if (citation.sourcePage != null) return `${marker}p.${citation.sourcePage}`
-
- const title = citationChapter(chapters, citation.chapterOrd)?.title?.trim()
- if (title) return `${marker}${title}`
-
- // No chapter list (or no match): show the human 1-based number rather than the stored ordinal.
- if (citation.chapterOrd != null) return `${marker}ch.${citation.chapterOrd + 1}`
-
- return marker.trim() || '—'
-}
-
-const SNIPPET_MAX = 40
-const SNIPPET_MIN = 12
-
-/**
- * A short, distinctive prefix of a citation's preview, cut at a word boundary. Kept short so it's
- * likely to sit within a single DOM text node — citation scroll locates the passage by searching the
- * rendered text for this snippet (the chunk offsets are into PlainText, not the rendered DOM). Both
- * web (AI-026b) and the mobile WebView (AI-026d) use it. Returns '' when there isn't enough to match on.
- */
-export function makeSnippet(preview: string): string {
- const text = preview.replace(/\s+/g, ' ').trim()
- if (text.length < SNIPPET_MIN) return ''
- if (text.length <= SNIPPET_MAX) return text
- const cut = text.slice(0, SNIPPET_MAX)
- const lastSpace = cut.lastIndexOf(' ')
- return lastSpace >= SNIPPET_MIN ? cut.slice(0, lastSpace) : cut
-}
diff --git a/packages/shared/src/types/api.ts b/packages/shared/src/types/api.ts
index 60a23f159..1113305ee 100644
--- a/packages/shared/src/types/api.ts
+++ b/packages/shared/src/types/api.ts
@@ -467,46 +467,3 @@ export interface UserBookChapterDto {
/** 1-based PDF page where this chapter starts. Null for EPUBs / unknown. */
sourceStartPage?: number | null
}
-
-// "Ask this book" (Phase 4 RAG, AI-025/026). Mirrors backend Contracts.Books.Ask*.
-export interface AskCitation {
- marker: number
- chunkId: string
- // Nullable at runtime for PDF citations that aren't chapter-anchored (ADR-012 S3b).
- // Typed non-null here for mobile back-compat; consumers must null-guard.
- chapterId: string
- chapterOrd: number
- charStart: number
- charEnd: number
- preview: string
- // Vision-RAG / PDF citations (ADR-012 S3): the source page to jump the Original
- // PDF viewer to, and an optional human-readable section path for tooltips.
- sourcePage?: number | null
- sectionPath?: string | null
-}
-
-export interface AskResponse {
- answer: string
- citations: AskCitation[]
- lastReadOrd: number
- insufficient: boolean
-}
-
-/**
- * One prior turn of the conversation, sent back to the server for multi-turn "Ask this book"
- * (AI-026e). The client bounds the history (last 6 turns) before sending.
- */
-export interface AskTurnDto {
- role: 'user' | 'assistant'
- content: string
-}
-
-/**
- * Identifies what the "Ask this book" panel is pointed at (AI-027 P2). A catalog `edition`
- * routes to `/books/{id}/...`; a user-uploaded `userbook` routes to `/me/books/{id}/...`.
- * The reader builds this from whichever book it loaded and threads it through the sheet.
- */
-export interface AskTarget {
- kind: 'edition' | 'userbook'
- id: string
-}