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.