From a5bedcf8183f781e4f28a9696cb4707015eb091f Mon Sep 17 00:00:00 2001 From: Vasyl Vdovychenko Date: Thu, 10 Sep 2026 14:59:56 -0400 Subject: [PATCH 1/2] chore: remove what the chat cut left behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inert, but exactly the residue the cut exists to remove. apps/mobile/src/lib/sse.ts and sseParser.ts had no importers left — their only caller was bookChat.ts. The web's lib/sse.ts is NOT dead and stays: useExplain still streams through it. ReaderShell kept pendingCitationRef and scrollToCitation alive, plus the block that consumed them on WebView load. The only writer of that ref was the deleted handleCitation, so the ref was permanently null and the block could never run. packages/shared/src/reader/citation.ts existed for that path only, as did AskCitation / AskResponse / AskTurnDto / AskTarget. AskMarkdown survives — BookInsightsSection renders insight Markdown through it — but it is named after a feature that no longer exists and its doc comment described "the renderer the Ask sheet already uses". Renamed to Markdown, same reasoning as onStudyBuddy → onAskAbout last week: a name pointing at something deleted is how the next reader concludes it is still there. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011rgEMvYYi4Egj99dVtvm6E --- .../{AskMarkdown.tsx => Markdown.tsx} | 7 +- .../library/BookInsightsSection.tsx | 6 +- .../src/components/reader/ReaderShell.tsx | 13 +- apps/mobile/src/lib/markdown.ts | 2 +- apps/mobile/src/lib/sse.ts | 85 ------------- apps/mobile/src/lib/sseParser.test.ts | 92 -------------- apps/mobile/src/lib/sseParser.ts | 119 ------------------ docs/05-features/assistant-handoff.md | 17 +++ packages/shared/src/index.ts | 1 - packages/shared/src/reader/citation.test.ts | 38 ------ packages/shared/src/reader/citation.ts | 69 ---------- packages/shared/src/types/api.ts | 43 ------- 12 files changed, 26 insertions(+), 466 deletions(-) rename apps/mobile/src/components/{AskMarkdown.tsx => Markdown.tsx} (96%) delete mode 100644 apps/mobile/src/lib/sse.ts delete mode 100644 apps/mobile/src/lib/sseParser.test.ts delete mode 100644 apps/mobile/src/lib/sseParser.ts delete mode 100644 packages/shared/src/reader/citation.test.ts delete mode 100644 packages/shared/src/reader/citation.ts 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/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 -} From 6549bca6d6605648091c36854a374eed485e08bb Mon Sep 17 00:00:00 2001 From: Vasyl Vdovychenko Date: Thu, 10 Sep 2026 15:01:03 -0400 Subject: [PATCH 2/2] docs(status): five web modules were dead before this work, not after it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/fuzzyMatch, lib/wordAtPoint, hooks/useOfflineDownload, hooks/useSwipe, hooks/useVocabLevel and styles/native-language-picker.css have no importers — and git grep on main says they had none before the chat cut either. Recorded rather than swept in: mixing someone else's dead code into a deletion PR is how a reviewer loses track of what the PR actually decided. Two of them (useSwipe, useOfflineDownload) are listed in STATUS.md's own hook inventory as if live. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011rgEMvYYi4Egj99dVtvm6E --- docs/STATUS.md | 8 ++++++++ 1 file changed, 8 insertions(+) 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.