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:
parent
3279e14bb2
commit
b345709c4b
11 changed files with 135 additions and 94 deletions
|
|
@ -289,12 +289,18 @@ CITE = (
|
||||||
"is about so the learner can go and attempt it.\n\n"
|
"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
|
# 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
|
# 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
|
# go and gather the rest. None of that is the learner's problem, and none of
|
||||||
# can practise is under the answer, as a control that builds the session.
|
# it is negotiable: the session is built by the button under the answer,
|
||||||
"Never describe your own retrieval — how many sources you were given, that "
|
# from whatever the answer cited, capped at twenty.
|
||||||
"you have not looked at more, or what you could go and fetch. If the learner "
|
"Never describe your own retrieval: not how many sources you were given, "
|
||||||
"asks for more questions than you can cite, name what there is in one "
|
"not that you have not looked at more, not what you could go and fetch, "
|
||||||
"sentence and stop. Do not write practice questions of your own.\n\n"
|
"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"
|
"Be brief: a few sentences or a short list.\n\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -311,3 +311,8 @@ class RetrievalTests(_AiModeBase):
|
||||||
self.assertIn('[[article:7]]', prompt)
|
self.assertIn('[[article:7]]', prompt)
|
||||||
self.assertIn('never cite a marker that is not listed here', prompt)
|
self.assertIn('never cite a marker that is not listed here', prompt)
|
||||||
self.assertIn('Never reveal the answer to a practice question', 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)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import useMediaQuery from '../hooks/useMediaQuery'
|
import useMediaQuery from '../hooks/useMediaQuery'
|
||||||
|
import useHeaderOffset from '../hooks/useHeaderOffset'
|
||||||
import { useSessionDrawer } from '../context/SessionDrawer'
|
import { useSessionDrawer } from '../context/SessionDrawer'
|
||||||
import RichText from './RichText'
|
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 */ }
|
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({
|
export default function ArticleReader({
|
||||||
article, activeSection = '', onOpenSection, idPrefix = '', landmark = true,
|
article, activeSection = '', onOpenSection, idPrefix = '', landmark = true,
|
||||||
// No rail, no bar, no chrome: the reader used as a second column.
|
// 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()
|
const { register: registerDrawer } = useSessionDrawer()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!narrow || bare) return undefined
|
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])
|
}, [narrow, bare, registerDrawer])
|
||||||
// Collapsing the contents rail hands its width to the prose.
|
// Collapsing the contents rail hands its width to the prose.
|
||||||
const [railOpen, setRailOpen] = useState(() => stored(RAIL_KEY, 'open') !== 'closed')
|
const [railOpen, setRailOpen] = useState(() => stored(RAIL_KEY, 'open') !== 'closed')
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
.asave-pop {
|
.asave-pop {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 6px);
|
top: calc(100% + 6px);
|
||||||
right: 0;
|
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
width: min(280px, calc(100vw - 32px));
|
width: min(280px, calc(100vw - 32px));
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
|
@ -15,6 +14,9 @@
|
||||||
box-shadow: 0 12px 32px rgba(15, 23, 42, .16);
|
box-shadow: 0 12px 32px rgba(15, 23, 42, .16);
|
||||||
text-align: left;
|
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-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-note, .asave-empty { margin: 0 0 8px; font-size: .76rem; color: var(--text-muted); line-height: 1.45; }
|
||||||
.asave-empty { margin-bottom: 0; }
|
.asave-empty { margin-bottom: 0; }
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,22 @@ import './ArticleSaveButton.css'
|
||||||
* was kept on the device for a while, because the API could not answer; that
|
* was kept on the device for a while, because the API could not answer; that
|
||||||
* was wrong on the second machine and silently so.
|
* 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 }) {
|
export default function ArticleSaveButton({ articleId }) {
|
||||||
const [open, setOpen] = useState(false)
|
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 [libraries, setLibraries] = useState([])
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [saved, setSaved] = useState([])
|
const [saved, setSaved] = useState([])
|
||||||
const wrap = useRef(null)
|
const wrap = useRef(null)
|
||||||
|
const anchor = useRef(null)
|
||||||
|
|
||||||
// Asked once per article rather than per library: fetching each library's
|
// 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
|
// contents to find out would also stamp every one of them as used and
|
||||||
|
|
@ -92,14 +101,20 @@ export default function ArticleSaveButton({ articleId }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="asave" ref={wrap}>
|
<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-expanded={open} aria-haspopup="dialog"
|
||||||
aria-label={isSaved ? `Saved to ${saved.length} librar${saved.length === 1 ? 'y' : 'ies'}` : 'Save this article to a library'}
|
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>
|
<span aria-hidden="true">{isSaved ? '★' : '☆'}</span>
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{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>
|
<p className="asave-title">Save this article</p>
|
||||||
{/* One box for both. Searching what you have and naming what you do
|
{/* 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
|
not are the same act — you type the name of the library you want
|
||||||
|
|
|
||||||
30
frontend/src/hooks/useHeaderOffset.js
Normal file
30
frontend/src/hooks/useHeaderOffset.js
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -162,7 +162,7 @@
|
||||||
/* A drawer from the left, like the session's questions and an article's
|
/* 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. */
|
contents — not a panel that pushes the conversation down the page. */
|
||||||
.ai-rail {
|
.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;
|
width: min(320px, 86vw); max-height: none; overflow-y: auto;
|
||||||
background: var(--card-bg); border-right: 1px solid var(--border);
|
background: var(--card-bg); border-right: 1px solid var(--border);
|
||||||
box-shadow: 8px 0 28px rgba(15, 23, 42, .18);
|
box-shadow: 8px 0 28px rgba(15, 23, 42, .18);
|
||||||
|
|
@ -170,7 +170,7 @@
|
||||||
transition: transform .18s ease, visibility .18s;
|
transition: transform .18s ease, visibility .18s;
|
||||||
}
|
}
|
||||||
.ai-rail.is-open { transform: none; visibility: visible; }
|
.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-rail-toggle { display: none; }
|
||||||
.ai-msg.is-user { max-width: 88%; }
|
.ai-msg.is-user { max-width: 88%; }
|
||||||
.ai-hero-title { font-size: 1.35rem; }
|
.ai-hero-title { font-size: 1.35rem; }
|
||||||
|
|
@ -209,24 +209,6 @@
|
||||||
@media (prefers-reduced-motion: reduce) { .ai-mic.is-live { animation: none; } }
|
@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
|
/* Sources are the evidence for the answer, so they are headed and counted
|
||||||
rather than trailing off the bottom of it. */
|
rather than trailing off the bottom of it. */
|
||||||
.ai-sources-head {
|
.ai-sources-head {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import ArticleLink from '../components/ArticleLink'
|
||||||
const ArticleSplitPane = lazy(() => import('../components/ArticleSplitPane'))
|
const ArticleSplitPane = lazy(() => import('../components/ArticleSplitPane'))
|
||||||
import { SplitViewProvider } from '../context/SplitViewContext'
|
import { SplitViewProvider } from '../context/SplitViewContext'
|
||||||
import useMediaQuery from '../hooks/useMediaQuery'
|
import useMediaQuery from '../hooks/useMediaQuery'
|
||||||
|
import useHeaderOffset from '../hooks/useHeaderOffset'
|
||||||
import { useSessionDrawer } from '../context/SessionDrawer'
|
import { useSessionDrawer } from '../context/SessionDrawer'
|
||||||
import useDictation, { canDictate } from '../hooks/useDictation'
|
import useDictation, { canDictate } from '../hooks/useDictation'
|
||||||
import './AiModePage.css'
|
import './AiModePage.css'
|
||||||
|
|
@ -105,16 +106,13 @@ const STARTERS_SHOWN = 4
|
||||||
* that reaches here has a source behind it. Numbering rather than inlining the
|
* that reaches here has a source behind it. Numbering rather than inlining the
|
||||||
* title keeps a sentence readable when it rests on three sources.
|
* 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 }) {
|
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
|
// The match swallows the space before the marker, so the number replaces it
|
||||||
// rather than following it and leaving a double gap.
|
// rather than following it and leaving a double gap.
|
||||||
// Written as a markdown link to the source's own anchor, so the number in
|
// 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 above them made the sources look like a footnote to the
|
||||||
button rather than the evidence for the answer. */
|
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">
|
<ol className="ai-sources">
|
||||||
{citations.map((citation, i) => (
|
{sources.map((citation, i) => (
|
||||||
<li key={citation.marker} id={`ai-source-${i + 1}`}>
|
<li key={citation.marker} id={`ai-source-${i + 1}`}>
|
||||||
<span className="ai-source-num">{i + 1}</span>
|
<span className="ai-source-num">{i + 1}</span>
|
||||||
{/* A cited question opens where it is, not somewhere else.
|
{citation.kind === 'article' || citation.kind === 'section' ? (
|
||||||
`/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' ? (
|
|
||||||
/* The same hover card a cross-reference in prose gets: what
|
/* 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 source says, and the choice of a tab or the pane beside
|
||||||
the answer. A source you have to leave the conversation to
|
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 && (
|
{onPractise && !built && (
|
||||||
<button type="button" className="ai-practise" disabled={practising}
|
<button type="button" className="ai-practise" disabled={practising}
|
||||||
onClick={onPractise}>
|
onClick={onPractise}>
|
||||||
{practising ? 'Building a session…' : `▶ ${practiseLabel(citations)}`}
|
{practising ? 'Building a session…' : '▶ Practise this'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{built && (
|
{built && (
|
||||||
<div className="ai-built" role="status">
|
<div className="ai-built" role="status">
|
||||||
<strong>
|
<strong>Your session is ready</strong>
|
||||||
{built.count} question{built.count === 1 ? '' : 's'} ready to sit
|
|
||||||
</strong>
|
|
||||||
<span>Sit it now, or leave it in your sessions and carry on here.</span>
|
<span>Sit it now, or leave it in your sessions and carry on here.</span>
|
||||||
<div className="ai-built-actions">
|
<div className="ai-built-actions">
|
||||||
<button type="button" className="btn btn-primary btn-sm"
|
<button type="button" className="btn btn-primary btn-sm"
|
||||||
|
|
@ -252,8 +236,11 @@ export default function AiModePage() {
|
||||||
const { register: registerDrawer } = useSessionDrawer()
|
const { register: registerDrawer } = useSessionDrawer()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!narrow) return undefined
|
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])
|
}, [narrow, registerDrawer])
|
||||||
|
const headerTop = useHeaderOffset()
|
||||||
const [railFolded, setRailFolded] = useState(false)
|
const [railFolded, setRailFolded] = useState(false)
|
||||||
const [moreStarters, setMoreStarters] = useState(false)
|
const [moreStarters, setMoreStarters] = useState(false)
|
||||||
// Which answer is being turned into a session, if any.
|
// Which answer is being turned into a session, if any.
|
||||||
|
|
@ -441,7 +428,11 @@ export default function AiModePage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SplitViewProvider value={splitView}>
|
<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
|
{/* 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
|
rail, the way it opens a session's questions and an article's
|
||||||
contents — one control, in the same place, on every page. */}
|
contents — one control, in the same place, on every page. */}
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,28 @@ describe('AI Mode', () => {
|
||||||
expect(sources[1]).toHaveAttribute('href', '/articles/7?section=abc')
|
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 () => {
|
it('says which sources an educator linked, since that is a stronger claim', async () => {
|
||||||
mockApi(threads, [answer])
|
mockApi(threads, [answer])
|
||||||
mount()
|
mount()
|
||||||
|
|
@ -177,8 +199,9 @@ describe('AI Mode', () => {
|
||||||
api.post.mockResolvedValue({ data: { quiz_id: 91, count: 6 } })
|
api.post.mockResolvedValue({ data: { quiz_id: 91, count: 6 } })
|
||||||
mount()
|
mount()
|
||||||
await userEvent.click(await screen.findByRole('button', { name: 'Febrile seizures' }))
|
await userEvent.click(await screen.findByRole('button', { name: 'Febrile seizures' }))
|
||||||
// Named after what it will build, and named before it is pressed.
|
// The button does not count anything: what is in the session is the
|
||||||
await userEvent.click(await screen.findByRole('button', { name: '▶ Practise these 2 topics' }))
|
// 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(
|
await waitFor(() => expect(api.post).toHaveBeenCalledWith(
|
||||||
'/ai/conversations/1/practice',
|
'/ai/conversations/1/practice',
|
||||||
expect.objectContaining({ message_id: 22, title: expect.stringContaining('AI Mode session from') })))
|
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
|
// Built, and offered rather than entered: leaving a conversation to sit
|
||||||
// twenty questions is a decision, and "Later" leaves the session in the
|
// twenty questions is a decision, and "Later" leaves the session in the
|
||||||
// list rather than throwing it away.
|
// 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()
|
expect(screen.getByRole('button', { name: 'Start now' })).toBeInTheDocument()
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Later' }))
|
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.
|
// 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' } } })
|
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()
|
expect(await screen.findByText(/No questions in your bank match/)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -329,6 +329,8 @@ export default function QuizPage() {
|
||||||
const [totalTime, setTotalTime] = useState(null)
|
const [totalTime, setTotalTime] = useState(null)
|
||||||
const [toast, setToast] = useState('')
|
const [toast, setToast] = useState('')
|
||||||
const [navOpen, setNavOpen] = useState(false)
|
const [navOpen, setNavOpen] = useState(false)
|
||||||
|
const navOpenRef = useRef(false)
|
||||||
|
useEffect(() => { navOpenRef.current = navOpen })
|
||||||
const [drawerTab, setDrawerTab] = useState('questions')
|
const [drawerTab, setDrawerTab] = useState('questions')
|
||||||
// The session rail is the navigator whenever there is room for it; the
|
// 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
|
// dropdown only exists for screens too narrow to show it. Matches the
|
||||||
|
|
@ -346,7 +348,14 @@ export default function QuizPage() {
|
||||||
const { register: registerDrawer } = sessionDrawer
|
const { register: registerDrawer } = sessionDrawer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasRail) return undefined
|
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])
|
}, [hasRail, registerDrawer])
|
||||||
const [expandedImagePath, setExpandedImagePath] = useState('')
|
const [expandedImagePath, setExpandedImagePath] = useState('')
|
||||||
const [imageZoom, setImageZoom] = useState(1)
|
const [imageZoom, setImageZoom] = useState(1)
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ export default function ResultsPage() {
|
||||||
const { register: registerDrawer } = useSessionDrawer()
|
const { register: registerDrawer } = useSessionDrawer()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasRail) return undefined
|
if (hasRail) return undefined
|
||||||
return registerDrawer(() => setNavOpen(true))
|
return registerDrawer(() => setNavOpen(open => !open))
|
||||||
}, [hasRail, registerDrawer])
|
}, [hasRail, registerDrawer])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue