fix: drawers that close, sources that are sources, and a model that does not haggle

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 23:34:24 +02:00
parent 3279e14bb2
commit b345709c4b
11 changed files with 135 additions and 94 deletions

View file

@ -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"
)

View file

@ -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)

View file

@ -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')

View file

@ -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; }

View file

@ -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 (
<span className="asave" ref={wrap}>
<button type="button" className={`article-tool${isSaved ? ' is-on' : ''}`}
<button type="button" ref={anchor} className={`article-tool${isSaved ? ' is-on' : ''}`}
aria-expanded={open} aria-haspopup="dialog"
aria-label={isSaved ? `Saved to ${saved.length} librar${saved.length === 1 ? 'y' : 'ies'}` : 'Save this article to a library'}
onClick={() => setOpen(v => !v)}>
onClick={() => {
// Measured when it opens rather than guessed: the same star sits at
// the left of the reading bar and could sit anywhere else later.
const box = anchor.current?.getBoundingClientRect?.()
if (box) setSide(box.right - PANEL < 8 ? 'left' : 'right')
setOpen(v => !v)
}}>
<span aria-hidden="true">{isSaved ? '★' : '☆'}</span>
</button>
{open && (
<div className="asave-pop" role="dialog" aria-label="Save this article to a library">
<div className={`asave-pop is-${side}`} role="dialog" aria-label="Save this article to a library">
<p className="asave-title">Save this article</p>
{/* 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

View file

@ -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
}

View file

@ -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 {

View file

@ -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. */
<>
<h3 className="ai-sources-head">Sources ({citations.length})</h3>
{sources.length > 0 && <h3 className="ai-sources-head">Sources ({sources.length})</h3>}
<ol className="ai-sources">
{citations.map((citation, i) => (
{sources.map((citation, i) => (
<li key={citation.marker} id={`ai-source-${i + 1}`}>
<span className="ai-source-num">{i + 1}</span>
{/* 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' ? (
<details className="ai-source-q">
<summary>{citation.title}</summary>
{citation.text && <p>{citation.text}</p>}
</details>
) : 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 && (
<button type="button" className="ai-practise" disabled={practising}
onClick={onPractise}>
{practising ? 'Building a session…' : `${practiseLabel(citations)}`}
{practising ? 'Building a session…' : '▶ Practise this'}
</button>
)}
{built && (
<div className="ai-built" role="status">
<strong>
{built.count} question{built.count === 1 ? '' : 's'} ready to sit
</strong>
<strong>Your session is ready</strong>
<span>Sit it now, or leave it in your sessions and carry on here.</span>
<div className="ai-built-actions">
<button type="button" className="btn btn-primary btn-sm"
@ -252,8 +236,11 @@ export default function AiModePage() {
const { register: registerDrawer } = useSessionDrawer()
useEffect(() => {
if (!narrow) return undefined
return registerDrawer(() => setRailOpen(true))
// A toggle. Pressing the same button again is how a thumb closes a drawer,
// and it did nothing the only way out was the strip of page to the right.
return registerDrawer(() => setRailOpen(open => !open))
}, [narrow, registerDrawer])
const headerTop = useHeaderOffset()
const [railFolded, setRailFolded] = useState(false)
const [moreStarters, setMoreStarters] = useState(false)
// Which answer is being turned into a session, if any.
@ -441,7 +428,11 @@ export default function AiModePage() {
return (
<SplitViewProvider value={splitView}>
<div className={`ai-page${railFolded ? ' is-folded' : ''}${splitSlug ? ' has-split' : ''}`}>
{/* The drawer starts below the header rather than at the top of the window:
the first chat in the list used to sit behind the navbar, unreadable and
untappable, and the button that opens it was covered too. */}
<div className={`ai-page${railFolded ? ' is-folded' : ''}${splitSlug ? ' has-split' : ''}`}
style={{ '--ai-top': `${headerTop}px` }}>
{/* No in-page Chats button. On a phone the menu in the header opens this
rail, the way it opens a session's questions and an article's
contents one control, in the same place, on every page. */}

View file

@ -61,6 +61,28 @@ describe('AI Mode', () => {
expect(sources[1]).toHaveAttribute('href', '/articles/7?section=abc')
})
it('keeps cited questions out of the source list', async () => {
// A question is not something you read; it is something you sit. Listing
// one as a source put the stem and a link to the editor behind it in
// the middle of the evidence for the answer.
const withQuestion = {
...answer,
content: 'Fever first [[article:7]]. There are questions on this [[question:4]].',
citations: [...answer.citations,
{ marker: '[[question:4]]', kind: 'question', id: 4, section_id: null,
title: 'A 4-month-old infant…', curated: false }],
}
mockApi(threads, [withQuestion])
mount()
const message = (await screen.findByText(/Fever first/)).closest('.ai-msg')
expect(within(message).getByText(/^Sources/)).toHaveTextContent('Sources (2)')
expect(message.textContent).not.toContain('A 4-month-old infant')
// And the marker for it leaves nothing behind in the prose.
expect(message.textContent).not.toContain('[[question:4]]')
// It still counts: the session is built from everything the answer cited.
expect(within(message).getByRole('button', { name: '▶ Practise this' })).toBeInTheDocument()
})
it('says which sources an educator linked, since that is a stronger claim', async () => {
mockApi(threads, [answer])
mount()
@ -177,8 +199,9 @@ describe('AI Mode', () => {
api.post.mockResolvedValue({ data: { quiz_id: 91, count: 6 } })
mount()
await userEvent.click(await screen.findByRole('button', { name: 'Febrile seizures' }))
// Named after what it will build, and named before it is pressed.
await userEvent.click(await screen.findByRole('button', { name: '▶ Practise these 2 topics' }))
// The button does not count anything: what is in the session is the
// builder's business, and a number here only invites haggling over it.
await userEvent.click(await screen.findByRole('button', { name: '▶ Practise this' }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith(
'/ai/conversations/1/practice',
expect.objectContaining({ message_id: 22, title: expect.stringContaining('AI Mode session from') })))
@ -186,14 +209,14 @@ describe('AI Mode', () => {
// Built, and offered rather than entered: leaving a conversation to sit
// twenty questions is a decision, and "Later" leaves the session in the
// list rather than throwing it away.
expect(await screen.findByText(/6 questions ready to sit/)).toBeInTheDocument()
expect(await screen.findByText(/Your session is ready/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Start now' })).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Later' }))
expect(screen.queryByText(/questions ready/)).not.toBeInTheDocument()
expect(screen.queryByText(/Your session is ready/)).not.toBeInTheDocument()
// Nothing in the bank matches: said plainly, not as a broken button.
api.post.mockRejectedValue({ response: { data: { detail: 'No questions in your bank match this conversation yet' } } })
await userEvent.click(screen.getByRole('button', { name: '▶ Practise these 2 topics' }))
await userEvent.click(screen.getByRole('button', { name: '▶ Practise this' }))
expect(await screen.findByText(/No questions in your bank match/)).toBeInTheDocument()
})

View file

@ -329,6 +329,8 @@ export default function QuizPage() {
const [totalTime, setTotalTime] = useState(null)
const [toast, setToast] = useState('')
const [navOpen, setNavOpen] = useState(false)
const navOpenRef = useRef(false)
useEffect(() => { navOpenRef.current = navOpen })
const [drawerTab, setDrawerTab] = useState('questions')
// The session rail is the navigator whenever there is room for it; the
// dropdown only exists for screens too narrow to show it. Matches the
@ -346,7 +348,14 @@ export default function QuizPage() {
const { register: registerDrawer } = sessionDrawer
useEffect(() => {
if (hasRail) return undefined
return registerDrawer(() => { setDrawerTab('questions'); setNavOpen(true) })
// Toggling, so the button that opened the drawer also shuts it. Read
// through a ref rather than closed over, so the registration does not have
// to be torn down and remade every time the drawer moves.
return registerDrawer(() => {
if (navOpenRef.current) { setNavOpen(false); return }
setDrawerTab('questions')
setNavOpen(true)
})
}, [hasRail, registerDrawer])
const [expandedImagePath, setExpandedImagePath] = useState('')
const [imageZoom, setImageZoom] = useState(1)

View file

@ -53,7 +53,7 @@ export default function ResultsPage() {
const { register: registerDrawer } = useSessionDrawer()
useEffect(() => {
if (hasRail) return undefined
return registerDrawer(() => setNavOpen(true))
return registerDrawer(() => setNavOpen(open => !open))
}, [hasRail, registerDrawer])
useEffect(() => {