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:
parent
18fa1913a4
commit
672e1c2014
3 changed files with 134 additions and 15 deletions
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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' }))
|
||||
|
|
|
|||
Loading…
Reference in a new issue