feat: citations you can click, and a session offered rather than entered

**The numbers were decoration.** `[1] [2] [3]` in an answer were plain text
that looked like references and did nothing — worse than not numbering them.
Each is now a control that takes the reader to the source it stands for, which
flashes so it is clear which one was meant.

**A session is built, then offered.** "Practise these 3 topics" used to build a
session and immediately leave the conversation for it. Leaving mid-conversation
to sit twenty questions is a decision: the session is now made either way and
the answer says so — *Start now*, or *Later*, which leaves it in the sessions
list and lets the chat carry on.

**And the chats rail is behind the header's menu on a phone**, like a session's
questions and an article's contents, opening as a drawer from the left with a
backdrop that closes it. The in-page "☰ Chats" bar is gone: one control, in the
same place, on every page.

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 22:37:46 +02:00
parent 18fa1913a4
commit 672e1c2014
3 changed files with 134 additions and 15 deletions

View file

@ -138,9 +138,19 @@
@media (max-width: 820px) {
.ai-page, .ai-page.is-folded { grid-template-columns: 1fr; }
.ai-rail { position: static; display: none; max-height: none; }
.ai-rail.is-open { display: block; }
.ai-rail-toggle { display: inline-block; }
/* 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;
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);
transform: translateX(-100%); visibility: hidden;
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-toggle { display: none; }
.ai-msg.is-user { max-width: 88%; }
.ai-hero-title { font-size: 1.35rem; }
}
@ -202,3 +212,34 @@
margin: 18px 0 8px; padding-top: 14px; border-top: 1px solid var(--border);
font-size: 0.95rem; font-weight: 700; color: var(--text);
}
/* A citation in the prose. Small, raised, and obviously a control it was
plain text that looked like a reference and did nothing. */
.ai-cite {
display: inline-block; margin: 0 1px; padding: 0 5px;
border-radius: 5px; text-decoration: none;
background: color-mix(in srgb, var(--primary) 10%, transparent);
color: var(--primary); font-size: 0.78em; font-weight: 700;
vertical-align: 1px; line-height: 1.5;
}
.ai-cite:hover { background: color-mix(in srgb, var(--primary) 22%, transparent); }
/* The source a number just pointed at, for a moment. */
.ai-sources li.is-called {
background: color-mix(in srgb, var(--primary) 12%, transparent);
border-radius: 8px;
transition: background .3s ease;
}
/* The session an answer just built. It exists either way "Later" leaves it
in the sessions list rather than throwing it away so both controls are
ordinary buttons and neither is a warning. */
.ai-built {
display: flex; flex-direction: column; gap: 4px; margin-top: 12px;
padding: 12px 14px; border-radius: 10px;
background: color-mix(in srgb, var(--primary) 7%, transparent);
border: 1px solid color-mix(in srgb, var(--primary) 20%, transparent);
}
.ai-built strong { font-size: 0.92rem; }
.ai-built span { font-size: 0.82rem; color: var(--text-muted); }
.ai-built-actions { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }

View file

@ -3,6 +3,8 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import api from '../api/client'
import useMediaQuery from '../hooks/useMediaQuery'
import { useSessionDrawer } from '../context/SessionDrawer'
import useDictation, { canDictate } from '../hooks/useDictation'
import './AiModePage.css'
import { sessionTitle } from '../utils/sessionTitle'
@ -106,17 +108,42 @@ function practiseLabel(citations) {
return `Practise ${topics === 1 ? 'this topic' : `these ${topics} topics`}`
}
function Answer({ content, citations, onPractise, practising }) {
function Answer({ content, citations, onPractise, practising, built, onStart, onLater }) {
const index = new Map(citations.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
// the prose is a control and not a decoration. It was plain text: three
// citations at the end of a sentence that looked like references and did
// nothing, which is worse than not numbering them at all.
const numbered = content.replace(CITATION, (_match, kind, ref) => {
const number = index.get(`[[${kind}:${ref}]]`)
return number ? ` [${number}]` : ''
return number ? ` [${number}](#ai-source-${number})` : ''
})
const jump = (event, number) => {
event.preventDefault()
const target = document.getElementById(`ai-source-${number}`)
if (!target) return
target.scrollIntoView({ block: 'center', behavior: 'smooth' })
// Flashed rather than left highlighted: it says which one without
// permanently marking a source as special.
target.classList.add('is-called')
setTimeout(() => target.classList.remove('is-called'), 1400)
}
const components = {
a: ({ node, href, children, ...props }) => {
const cited = /^#ai-source-(\d+)$/.exec(href || '')
if (!cited) return <a {...props} href={href} target="_blank" rel="noopener noreferrer">{children}</a>
return (
<a {...props} href={href} className="ai-cite"
aria-label={`Source ${cited[1]}`}
onClick={event => jump(event, cited[1])}>{children}</a>
)
},
}
return (
<div className="ai-answer">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{numbered}</ReactMarkdown>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>{numbered}</ReactMarkdown>
{citations.length > 0 && (
/* At the end, under a heading that counts them. They are what the
answer rests on, so they are read after it putting the practise
@ -126,7 +153,7 @@ function Answer({ content, citations, onPractise, practising }) {
<h3 className="ai-sources-head">Sources ({citations.length})</h3>
<ol className="ai-sources">
{citations.map((citation, i) => (
<li key={citation.marker}>
<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
@ -154,12 +181,26 @@ function Answer({ content, citations, onPractise, practising }) {
to build from, and named after what it will build it used to sit
under every reply including "how can I help you today?", where it
offered to make a session out of nothing and said only "this". */}
{onPractise && (
{onPractise && !built && (
<button type="button" className="ai-practise" disabled={practising}
onClick={onPractise}>
{practising ? 'Building a session…' : `${practiseLabel(citations)}`}
</button>
)}
{built && (
<div className="ai-built" role="status">
<strong>
{built.count} question{built.count === 1 ? '' : 's'} 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"
onClick={() => onStart(built.quizId)}>Start now</button>
<button type="button" className="btn btn-secondary btn-sm"
onClick={onLater}>Later</button>
</div>
</div>
)}
</>
)}
</div>
@ -190,10 +231,23 @@ export default function AiModePage() {
// Two different rails: an overlay on a narrow screen, a column that can be
// folded away on a wide one.
const [railOpen, setRailOpen] = useState(false)
// The header's menu opens the chats on a phone, the way it opens a session's
// questions and an article's contents. Above 820px the rail is beside the
// conversation and there is nothing to open.
const narrow = useMediaQuery('(max-width: 820px)')
const { register: registerDrawer } = useSessionDrawer()
useEffect(() => {
if (!narrow) return undefined
return registerDrawer(() => setRailOpen(true))
}, [narrow, registerDrawer])
const [railFolded, setRailFolded] = useState(false)
const [moreStarters, setMoreStarters] = useState(false)
// Which answer is being turned into a session, if any.
const [practising, setPractising] = useState(null)
//: The session just built from an answer, until the learner says what to do
//: with it. It exists on the server either way "later" is not a promise to
//: build one, it is a session already in the list.
const [built, setBuilt] = useState(null)
const endRef = useRef(null)
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
@ -270,7 +324,11 @@ export default function AiModePage() {
// of "Custom test from Sep 12, 1 AM".
const res = await api.post(`/ai/conversations/${activeId}/practice`,
{ message_id: messageId, title: sessionTitle('AI Mode session') })
navigate(`/study/${res.data.quiz_id}?start=1`)
// Built, and offered rather than entered. Leaving mid-conversation to
// sit twenty questions is a decision, and it was being made for the
// learner by a button that read "Practise these 3 topics" the session
// is now made, kept, and waiting either way.
setBuilt({ messageId, quizId: res.data.quiz_id, count: res.data.questions_count })
} catch (err) {
setError(apiError(err, 'Could not build a session from this answer'))
} finally {
@ -354,10 +412,12 @@ export default function AiModePage() {
return (
<div className={`ai-page${railFolded ? ' is-folded' : ''}`}>
<button className="ai-rail-toggle" aria-expanded={railOpen}
onClick={() => setRailOpen(v => !v)}>
{railOpen ? '✕ Close chats' : '☰ Chats'}
</button>
{/* 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. */}
{railOpen && (
<div className="ai-rail-backdrop" aria-hidden="true" onClick={() => setRailOpen(false)} />
)}
<aside className={`ai-rail${railOpen ? ' is-open' : ''}`}>
<div className="ai-rail-head">
@ -403,6 +463,9 @@ export default function AiModePage() {
? <p>{message.content}</p>
: <Answer content={message.content} citations={message.citations || []}
practising={practising === message.id}
built={built?.messageId === message.id ? built : null}
onStart={quizId => navigate(`/study/${quizId}?start=1`)}
onLater={() => setBuilt(null)}
onPractise={() => practise(message.id)} />}
</div>
))}

View file

@ -46,9 +46,16 @@ describe('AI Mode', () => {
// The marker itself never reaches the reader.
expect(message.textContent).not.toContain('[[article:7]]')
expect(message.textContent).toContain('Fever first [1]')
// The number in the prose is a control, not a decoration: it takes the
// reader to the source it stands for. It used to be plain text that
// looked like a reference and did nothing.
const cite = within(message).getByRole('link', { name: 'Source 1' })
expect(cite).toHaveAttribute('href', '#ai-source-1')
expect(cite).toHaveTextContent('1')
const sources = within(message).getAllByRole('link')
.filter(node => !node.getAttribute('href').startsWith('#'))
expect(sources[0]).toHaveAttribute('href', '/articles/7')
// A section citation deep-links into the section it came from.
expect(sources[1]).toHaveAttribute('href', '/articles/7?section=abc')
@ -136,7 +143,7 @@ describe('AI Mode', () => {
it('turns an answer into a session, and says so when there is nothing to sit', async () => {
mockApi(threads, [answer])
api.post.mockResolvedValue({ data: { quiz_id: 91, count: 6 } })
api.post.mockResolvedValue({ data: { quiz_id: 91, questions_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.
@ -145,6 +152,14 @@ describe('AI Mode', () => {
'/ai/conversations/1/practice',
expect.objectContaining({ message_id: 22, title: expect.stringContaining('AI Mode session from') })))
// 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/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Start now' })).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Later' }))
expect(screen.queryByText(/questions 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' }))