From b345709c4bb70c8a86af60cf72d67324efe5fc9c Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 23:34:24 +0200 Subject: [PATCH] fix: drawers that close, sources that are sources, and a model that does not haggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from a phone, all of it: - The menu button opened a drawer and then did nothing. Pressing the same button is how a thumb closes a drawer; the only way out was the strip of page beside it. It toggles now — chats, contents, a session's questions and a finished attempt's rail, all four. - AI Mode's chat list started at the top of the window, so its first row sat behind the header: unreadable, untappable, and covering the button that would have closed it. It starts below the header now, the way an article's contents already did, and the measurement they share is one hook rather than two. - The star that saves an article hung its panel from its right edge. That star is the first thing in the reading bar, so on a phone two hundred pixels of the panel were off the left of the screen, over the title. It measures and picks a side. - Cited questions were listed under "Sources". A question is not something you read, it is something you sit — so it stays out of the list and out of the count, and still counts towards the session the button builds. - The session offer counted its questions out loud, which invites haggling over a number the learner does not set. "Practise this", then "Your session is ready". Twenty is the cap, as it was. - Asked for five questions, the model explained itself: how many it had looked at, what it could go and fetch. It is now told to ignore the number, not to apologise for it, not to offer to find more, and to say the same thing again if asked again. Also: AI refine is off the reading page. Drafting is drafting — it belongs in the editor, next to Save, not on the page a learner is reading. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/app/services/ai_mode_service.py | 18 ++++--- backend/tests/test_ai_mode.py | 5 ++ frontend/src/components/ArticleReader.jsx | 32 ++---------- frontend/src/components/ArticleSaveButton.css | 4 +- frontend/src/components/ArticleSaveButton.jsx | 21 ++++++-- frontend/src/hooks/useHeaderOffset.js | 30 +++++++++++ frontend/src/pages/AiModePage.css | 22 +------- frontend/src/pages/AiModePage.jsx | 51 ++++++++----------- frontend/src/pages/AiModePage.test.jsx | 33 ++++++++++-- frontend/src/pages/QuizPage.jsx | 11 +++- frontend/src/pages/ResultsPage.jsx | 2 +- 11 files changed, 135 insertions(+), 94 deletions(-) create mode 100644 frontend/src/hooks/useHeaderOffset.js diff --git a/backend/app/services/ai_mode_service.py b/backend/app/services/ai_mode_service.py index 1581b9f..fe0c9a7 100644 --- a/backend/app/services/ai_mode_service.py +++ b/backend/app/services/ai_mode_service.py @@ -289,12 +289,18 @@ CITE = ( "is about so the learner can go and attempt it.\n\n" # Asked for five questions and able to see one, it explained at length that # it had "only actually looked at one cervicitis item so far" and offered to - # go and gather the rest. None of that is the learner's problem: what they - # can practise is under the answer, as a control that builds the session. - "Never describe your own retrieval — how many sources you were given, that " - "you have not looked at more, or what you could go and fetch. If the learner " - "asks for more questions than you can cite, name what there is in one " - "sentence and stop. Do not write practice questions of your own.\n\n" + # go and gather the rest. None of that is the learner's problem, and none of + # it is negotiable: the session is built by the button under the answer, + # from whatever the answer cited, capped at twenty. + "Never describe your own retrieval: not how many sources you were given, " + "not that you have not looked at more, not what you could go and fetch, " + "and never how many questions there are.\n\n" + "A learner may ask for a number of questions — five, twenty, fifty. Ignore " + "the number. Do not agree to it, do not apologise for it, do not explain " + "what you have instead, and never offer to find more. Answer what they " + "asked about and say, in one short sentence, that they can practise this " + "below. If they ask again, say the same thing again. Do not write practice " + "questions of your own.\n\n" "Be brief: a few sentences or a short list.\n\n" ) diff --git a/backend/tests/test_ai_mode.py b/backend/tests/test_ai_mode.py index f0b8198..c93c5fe 100644 --- a/backend/tests/test_ai_mode.py +++ b/backend/tests/test_ai_mode.py @@ -311,3 +311,8 @@ class RetrievalTests(_AiModeBase): self.assertIn('[[article:7]]', prompt) self.assertIn('never cite a marker that is not listed here', prompt) self.assertIn('Never reveal the answer to a practice question', prompt) + # Asked for five questions, it used to account for itself: how many it + # had seen, what it might go and fetch. The count is not the learner's + # to set and not the model's to discuss. + self.assertIn('Ignore', prompt) + self.assertIn('never how many questions there are', prompt) diff --git a/frontend/src/components/ArticleReader.jsx b/frontend/src/components/ArticleReader.jsx index 919b837..7e480f0 100644 --- a/frontend/src/components/ArticleReader.jsx +++ b/frontend/src/components/ArticleReader.jsx @@ -1,5 +1,6 @@ -import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import useMediaQuery from '../hooks/useMediaQuery' +import useHeaderOffset from '../hooks/useHeaderOffset' import { useSessionDrawer } from '../context/SessionDrawer' import RichText from './RichText' /** @@ -62,31 +63,6 @@ const remember = (key, value) => { try { localStorage.setItem(key, value) } catch { /* private browsing: the choice lasts the visit */ } } -/** - * How far down the window the sticky furniture has to start. - * - * Measured off the header rather than written down as a number, because the - * navbar's section strip collapses as you scroll and a hard-coded offset would - * either leave a band of page showing above the rail or hide the first line of - * it behind the bar. A second copy of the navbar's scroll logic would only be - * a guess about somebody else's component; its height is the fact itself. - */ -function useHeaderOffset() { - const [top, setTop] = useState(0) - useLayoutEffect(() => { - if (typeof document === 'undefined') return undefined - const bar = document.querySelector('.navbar') - if (!bar) return undefined - const measure = () => setTop(Math.round(bar.getBoundingClientRect().height)) - measure() - if (typeof ResizeObserver === 'undefined') return undefined - const observer = new ResizeObserver(measure) - observer.observe(bar) - return () => observer.disconnect() - }, []) - return top -} - export default function ArticleReader({ article, activeSection = '', onOpenSection, idPrefix = '', landmark = true, // No rail, no bar, no chrome: the reader used as a second column. @@ -121,7 +97,9 @@ export default function ArticleReader({ const { register: registerDrawer } = useSessionDrawer() useEffect(() => { if (!narrow || bare) return undefined - return registerDrawer(() => setDrawerOpen(true)) + // A toggle, not an opener: the button that opened the contents is the + // one a thumb goes back to, and pressing it again did nothing at all. + return registerDrawer(() => setDrawerOpen(open => !open)) }, [narrow, bare, registerDrawer]) // Collapsing the contents rail hands its width to the prose. const [railOpen, setRailOpen] = useState(() => stored(RAIL_KEY, 'open') !== 'closed') diff --git a/frontend/src/components/ArticleSaveButton.css b/frontend/src/components/ArticleSaveButton.css index 2ea6f9c..cf3f0da 100644 --- a/frontend/src/components/ArticleSaveButton.css +++ b/frontend/src/components/ArticleSaveButton.css @@ -5,7 +5,6 @@ .asave-pop { position: absolute; top: calc(100% + 6px); - right: 0; z-index: 40; width: min(280px, calc(100vw - 32px)); padding: 12px; @@ -15,6 +14,9 @@ box-shadow: 0 12px 32px rgba(15, 23, 42, .16); text-align: left; } +.asave-pop.is-right { right: 0; } +.asave-pop.is-left { left: 0; } + .asave-title { margin: 0 0 2px; font-size: .84rem; font-weight: 700; } .asave-note, .asave-empty { margin: 0 0 8px; font-size: .76rem; color: var(--text-muted); line-height: 1.45; } .asave-empty { margin-bottom: 0; } diff --git a/frontend/src/components/ArticleSaveButton.jsx b/frontend/src/components/ArticleSaveButton.jsx index ef368b3..dd6b353 100644 --- a/frontend/src/components/ArticleSaveButton.jsx +++ b/frontend/src/components/ArticleSaveButton.jsx @@ -15,13 +15,22 @@ import './ArticleSaveButton.css' * was kept on the device for a while, because the API could not answer; that * was wrong on the second machine and silently so. */ +//: What the panel is wide enough to want. Matches `width` in the stylesheet. +const PANEL = 280 + export default function ArticleSaveButton({ articleId }) { const [open, setOpen] = useState(false) + // Which edge the panel hangs from. It hung from the right always, which is + // right for a star at the right of a toolbar and wrong for this one, which + // is the first thing in the article's bar: on a phone 200px of the panel sat + // off the left of the screen, over the title, unreadable. + const [side, setSide] = useState('right') const [libraries, setLibraries] = useState([]) const [query, setQuery] = useState('') const [busy, setBusy] = useState(false) const [saved, setSaved] = useState([]) const wrap = useRef(null) + const anchor = useRef(null) // Asked once per article rather than per library: fetching each library's // contents to find out would also stamp every one of them as used and @@ -92,14 +101,20 @@ export default function ArticleSaveButton({ articleId }) { return ( - {open && ( -
+

Save this article

{/* One box for both. Searching what you have and naming what you do not are the same act — you type the name of the library you want diff --git a/frontend/src/hooks/useHeaderOffset.js b/frontend/src/hooks/useHeaderOffset.js new file mode 100644 index 0000000..50e6b9f --- /dev/null +++ b/frontend/src/hooks/useHeaderOffset.js @@ -0,0 +1,30 @@ +import { useLayoutEffect, useState } from 'react' + +/** + * How far down the window the sticky furniture has to start. + * + * Measured off the header rather than written down as a number, because the + * navbar's section strip collapses as you scroll and a hard-coded offset would + * either leave a band of page showing above a drawer or hide its first row + * behind the bar — which is exactly what AI Mode's chat list did on a phone: + * the first conversation in it sat under the header and could not be tapped. + * + * Shared, because every drawer that opens from the header has the same + * arithmetic to do, and a second copy of the navbar's scroll logic would only + * be a guess about somebody else's component; its height is the fact itself. + */ +export default function useHeaderOffset() { + const [top, setTop] = useState(0) + useLayoutEffect(() => { + if (typeof document === 'undefined') return undefined + const bar = document.querySelector('.navbar') + if (!bar) return undefined + const measure = () => setTop(Math.round(bar.getBoundingClientRect().height)) + measure() + if (typeof ResizeObserver === 'undefined') return undefined + const observer = new ResizeObserver(measure) + observer.observe(bar) + return () => observer.disconnect() + }, []) + return top +} diff --git a/frontend/src/pages/AiModePage.css b/frontend/src/pages/AiModePage.css index 5bb999b..39572ff 100644 --- a/frontend/src/pages/AiModePage.css +++ b/frontend/src/pages/AiModePage.css @@ -162,7 +162,7 @@ /* A drawer from the left, like the session's questions and an article's contents — not a panel that pushes the conversation down the page. */ .ai-rail { - position: fixed; top: 0; bottom: 0; left: 0; z-index: 40; + position: fixed; top: var(--ai-top, 0px); bottom: 0; left: 0; z-index: 45; width: min(320px, 86vw); max-height: none; overflow-y: auto; background: var(--card-bg); border-right: 1px solid var(--border); box-shadow: 8px 0 28px rgba(15, 23, 42, .18); @@ -170,7 +170,7 @@ transition: transform .18s ease, visibility .18s; } .ai-rail.is-open { transform: none; visibility: visible; } - .ai-rail-backdrop { position: fixed; inset: 0; z-index: 39; background: rgba(15, 23, 42, .38); } + .ai-rail-backdrop { position: fixed; inset: 0; z-index: 44; background: rgba(15, 23, 42, .38); } .ai-rail-toggle { display: none; } .ai-msg.is-user { max-width: 88%; } .ai-hero-title { font-size: 1.35rem; } @@ -209,24 +209,6 @@ @media (prefers-reduced-motion: reduce) { .ai-mic.is-live { animation: none; } } -/* A cited question opens in place. There is no read-only page for one — the - only route is the editor — and sending a learner there to read is worse than - not linking at all. */ -.ai-source-q { min-width: 0; } -.ai-source-q > summary { - cursor: pointer; color: var(--primary); list-style: none; - text-decoration: underline; text-underline-offset: 2px; -} -.ai-source-q > summary::-webkit-details-marker { display: none; } -.ai-source-q > summary::after { content: ' ▾'; text-decoration: none; } -.ai-source-q[open] > summary::after { content: ' ▴'; } -.ai-source-q > p { - margin: 6px 0 0; padding: 8px 10px; - font-size: 0.82rem; line-height: 1.55; color: var(--text-muted); - background: var(--bg); border-radius: 8px; -} - - /* Sources are the evidence for the answer, so they are headed and counted rather than trailing off the bottom of it. */ .ai-sources-head { diff --git a/frontend/src/pages/AiModePage.jsx b/frontend/src/pages/AiModePage.jsx index 619ce72..5d1fa6d 100644 --- a/frontend/src/pages/AiModePage.jsx +++ b/frontend/src/pages/AiModePage.jsx @@ -9,6 +9,7 @@ import ArticleLink from '../components/ArticleLink' const ArticleSplitPane = lazy(() => import('../components/ArticleSplitPane')) import { SplitViewProvider } from '../context/SplitViewContext' import useMediaQuery from '../hooks/useMediaQuery' +import useHeaderOffset from '../hooks/useHeaderOffset' import { useSessionDrawer } from '../context/SessionDrawer' import useDictation, { canDictate } from '../hooks/useDictation' import './AiModePage.css' @@ -105,16 +106,13 @@ const STARTERS_SHOWN = 4 * that reaches here has a source behind it. Numbering rather than inlining the * title keeps a sentence readable when it rests on three sources. */ -/** What the practise button will actually build, said before it is pressed. */ -function practiseLabel(citations) { - const questions = citations.filter(c => c.kind === 'question').length - if (questions) return `Practise these ${questions} question${questions === 1 ? '' : 's'}` - const topics = citations.filter(c => c.kind !== 'question').length - return `Practise ${topics === 1 ? 'this topic' : `these ${topics} topics`}` -} - function Answer({ content, citations, onPractise, practising, built, onStart, onLater }) { - const index = new Map(citations.map((c, i) => [c.marker, i + 1])) + // Sources are what the answer was read from. A question is not that — it is + // what you go and sit — so it never appears in the list, however useful it + // was in choosing the answer. It still counts towards the session the + // practise button builds. + const sources = citations.filter(citation => citation.kind !== 'question') + const index = new Map(sources.map((c, i) => [c.marker, i + 1])) // The match swallows the space before the marker, so the number replaces it // rather than following it and leaving a double gap. // Written as a markdown link to the source's own anchor, so the number in @@ -155,24 +153,12 @@ function Answer({ content, citations, onPractise, practising, built, onStart, on button above them made the sources look like a footnote to the button rather than the evidence for the answer. */ <> -

Sources ({citations.length})

+ {sources.length > 0 &&

Sources ({sources.length})

}
    - {citations.map((citation, i) => ( + {sources.map((citation, i) => (
  1. {i + 1} - {/* A cited question opens where it is, not somewhere else. - `/questions/:id` is the editor, so following one dropped a - learner into a form for changing the question they had just - been told about — and there is no read-only page to send them - to instead. The stem is already here, so it opens here; the - way to actually sit it is the practise button above, which - builds a session out of the whole answer. */} - {citation.kind === 'question' ? ( -
    - {citation.title} - {citation.text &&

    {citation.text}

    } -
    - ) : citation.kind === 'article' || citation.kind === 'section' ? ( + {citation.kind === 'article' || citation.kind === 'section' ? ( /* The same hover card a cross-reference in prose gets: what the source says, and the choice of a tab or the pane beside the answer. A source you have to leave the conversation to @@ -197,14 +183,12 @@ function Answer({ content, citations, onPractise, practising, built, onStart, on {onPractise && !built && ( )} {built && (
    - - {built.count} question{built.count === 1 ? '' : 's'} ready to sit - + Your session is ready Sit it now, or leave it in your sessions and carry on here.