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 (