diff --git a/frontend/src/pages/AiModePage.css b/frontend/src/pages/AiModePage.css index cd62e67..972efcc 100644 --- a/frontend/src/pages/AiModePage.css +++ b/frontend/src/pages/AiModePage.css @@ -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; } diff --git a/frontend/src/pages/AiModePage.jsx b/frontend/src/pages/AiModePage.jsx index 262cc31..36285cc 100644 --- a/frontend/src/pages/AiModePage.jsx +++ b/frontend/src/pages/AiModePage.jsx @@ -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 {children} + return ( + jump(event, cited[1])}>{children} + ) + }, + } return (
- {numbered} + {numbered} {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 }) {

Sources ({citations.length})

    {citations.map((citation, i) => ( -
  1. +
  2. {i + 1} {/* 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 && ( )} + {built && ( +
    + + {built.count} question{built.count === 1 ? '' : 's'} ready + + Sit it now, or leave it in your sessions and carry on here. +
    + + +
    +
    + )} )}
@@ -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 (
- + {/* 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 && ( +