diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 31b5b0c..122204f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -63,6 +63,7 @@ function AppLayout() { // Keyed by path so navigating away from a broken page clears the error. const location = useLocation() const [searching, setSearching] = useState(false) + const inSession = location.pathname.startsWith('/study/') useEffect(() => { const onKey = (event) => { @@ -93,7 +94,11 @@ function AppLayout() { dropped. */} setSearching(false)} /> setSearching(true)} /> -
+ {/* A session takes the window. The page's own gutters and 1200px cap + are for reading; a player is a fixed-height box whose columns scroll + inside it, and every pixel the container reserves is a pixel the + question does not get. */} +
diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 066a0dd..02eb01d 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -190,6 +190,7 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) { const sessionDrawer = useSessionDrawer() const [jobs, setJobs] = useState([]) const location = useLocation() + const inSession = location.pathname.startsWith('/study/') const isModerator = user?.role === 'admin' || user?.role === 'moderator' // Educators granted a category also manage questions, without a moderator role. const [canManageQuestions, setCanManageQuestions] = useState(false) @@ -304,7 +305,11 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) {
- {user && ( + {/* Gone while a session is open. Every link on it leaves the session + you are sitting, and the room it takes is room the question wanted — + which is the whole argument for the player being a fixed-height box. + It comes back when you leave. */} + {user && !inSession && ( /* Focus-within keeps it open for a keyboard user tabbing into links that are visually gone. */
diff --git a/frontend/src/pages/AiModePage.css b/frontend/src/pages/AiModePage.css index 19990a5..149b15c 100644 --- a/frontend/src/pages/AiModePage.css +++ b/frontend/src/pages/AiModePage.css @@ -1,13 +1,32 @@ /* AI Mode: a rail of threads beside the conversation. */ -.ai-page { display: grid; grid-template-columns: 230px 1fr; gap: 18px; align-items: start; max-width: 1060px; margin: 0 auto; } +.ai-page { display: grid; grid-template-columns: 250px 1fr; gap: 18px; align-items: start; max-width: 1060px; margin: 0 auto; } +/* Folded, the rail keeps only its two controls, and the conversation takes the + width back rather than leaving a column of nothing beside it. */ +.ai-page.is-folded { grid-template-columns: 52px 1fr; } .ai-rail { position: sticky; top: 76px; max-height: calc(100dvh - 100px); overflow-y: auto; background: var(--card-bg); border: 1px solid var(--border); - border-radius: 12px; padding: 12px; + border-radius: 12px; padding: 10px; +} +.ai-rail-head { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-bottom: 8px; } +.ai-page.is-folded .ai-rail-head { flex-direction: column; } +.ai-rail-fold, .ai-compose { + flex-shrink: 0; width: 32px; height: 32px; padding: 0; cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; + background: none; border: 1px solid transparent; border-radius: 8px; + color: var(--text-muted); font: inherit; font-size: 0.95rem; +} +.ai-rail-fold:hover, .ai-compose:hover { border-color: var(--border); background: var(--bg); color: var(--primary); } + +.ai-rail-group + .ai-rail-group { margin-top: 12px; } +/* The age is a label on the list, not a heading anyone reads down the page. */ +.ai-rail-age { + margin: 0 0 4px; padding: 0 8px; + font-size: 0.68rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; + color: var(--text-subtle); } -.ai-new { width: 100%; margin-bottom: 10px; } .ai-rail ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; } .ai-rail li { display: flex; align-items: center; gap: 4px; } .ai-thread { @@ -27,11 +46,14 @@ .ai-rail-toggle { display: none; margin-bottom: 10px; } .ai-main { min-width: 0; display: flex; flex-direction: column; gap: 12px; } +/* Nothing asked yet: the box is the page, so it sits in the middle of the + screen rather than clinging to the bottom of an empty one. */ +.ai-main.is-blank { min-height: calc(100dvh - 180px); justify-content: center; align-items: center; gap: 18px; } -.ai-intro { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 28px; } -.ai-intro h1 { margin: 0 0 8px; font-size: 1.3rem; } -.ai-intro p { margin: 0 0 10px; color: var(--text-muted); font-size: 0.92rem; line-height: 1.65; max-width: 60ch; } -.ai-intro-note { font-size: 0.86rem !important; color: var(--text-subtle) !important; } +.ai-hero-title { + margin: 0; text-align: center; font-size: 1.7rem; line-height: 1.25; + color: var(--text); max-width: 20ch; +} .ai-thread-view { display: flex; flex-direction: column; gap: 12px; } .ai-msg { max-width: 100%; } @@ -72,26 +94,55 @@ .ai-error { color: var(--wrong-fg); font-size: 0.85rem; margin: 0; } +/* The field and its controls are one object with one outline, so the microphone + reads as part of the question rather than as a button parked next to it. */ .ai-composer { - display: flex; gap: 8px; align-items: flex-end; - position: sticky; bottom: 0; background: var(--bg); - /* Clear of the home indicator, or the send button sits under it. */ - padding-bottom: calc(12px + env(safe-area-inset-bottom)); + width: 100%; display: flex; flex-direction: column; gap: 6px; + position: sticky; bottom: 0; z-index: 1; + padding: 8px 8px calc(8px + env(safe-area-inset-bottom)); + background: var(--input-bg); border: 1px solid var(--border); border-radius: 14px; } +.ai-composer:focus-within { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); } .ai-composer textarea { - flex: 1; min-width: 0; resize: vertical; padding: 11px 14px; - border: 1px solid var(--border); border-radius: 10px; - background: var(--input-bg); color: var(--text); font: inherit; font-size: 0.92rem; + width: 100%; min-width: 0; resize: none; padding: 6px 8px; + background: none; border: 0; color: var(--text); font: inherit; font-size: 0.95rem; line-height: 1.5; } -.ai-composer textarea:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-color: var(--primary); } -.ai-composer .btn { min-height: 44px; } +.ai-composer textarea:focus { outline: none; } +.ai-composer-tools { display: flex; align-items: center; justify-content: flex-end; gap: 8px; } +.ai-send { min-height: 34px; } + +.ai-composer.is-hero { + position: static; max-width: 640px; + padding: 12px 12px 10px; border-radius: 18px; box-shadow: var(--card-shadow); +} +.ai-composer.is-hero textarea { font-size: 1rem; } + +.ai-hero-foot { width: 100%; max-width: 640px; display: flex; flex-direction: column; align-items: center; gap: 12px; } +.ai-tip { + margin: 0; text-align: center; max-width: 56ch; + font-size: 0.83rem; line-height: 1.6; color: var(--text-muted); +} +.ai-starters { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; } +.ai-starter { + cursor: pointer; padding: 8px 14px; border-radius: 20px; + background: var(--card-bg); border: 1px solid var(--border); color: var(--text); + font: inherit; font-size: 0.82rem; text-align: left; +} +.ai-starter:hover { border-color: var(--primary); color: var(--primary); } +.ai-starters-more { + cursor: pointer; background: none; border: 0; padding: 4px 6px; + color: var(--text-subtle); font: inherit; + font-size: 0.7rem; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase; +} +.ai-starters-more:hover { color: var(--primary); } @media (max-width: 820px) { - .ai-page { grid-template-columns: 1fr; } + .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; } .ai-msg.is-user { max-width: 88%; } + .ai-hero-title { font-size: 1.35rem; } } /* Reading, then practice. Quiet next to the answer — an offer, not the point @@ -108,7 +159,8 @@ /* Speaking instead of typing. Red while it is listening, because a microphone you have forgotten is on is the one thing this must never be. */ .ai-mic { - flex-shrink: 0; width: 42px; height: 42px; font-size: 1rem; cursor: pointer; + flex-shrink: 0; margin-right: auto; + width: 34px; height: 34px; font-size: 0.95rem; cursor: pointer; border: 1px solid var(--border); border-radius: 10px; background: var(--card-bg); color: var(--text-muted); } diff --git a/frontend/src/pages/AiModePage.jsx b/frontend/src/pages/AiModePage.jsx index 93dfc14..cfd50b1 100644 --- a/frontend/src/pages/AiModePage.jsx +++ b/frontend/src/pages/AiModePage.jsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import { Link, useNavigate } from 'react-router-dom' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Link, useNavigate, useSearchParams } from 'react-router-dom' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import api from '../api/client' @@ -23,6 +23,73 @@ export const citationHref = (citation) => { return '/flashcards' } +const DAY = 86400000 + +/** + * When a thread was last touched, in milliseconds. + * + * The API sends `updated_at` as a naive UTC timestamp, and JavaScript reads a + * zoneless ISO string as local time — which would slide a chat a whole + * timezone's worth into the wrong day for anyone who is not on UTC. Putting the + * marker back says what the server meant. + */ +const asTime = (value) => { + if (!value) return null + const text = String(value) + const ms = Date.parse(/(?:Z|[+-]\d{2}:?\d{2})$/.test(text) ? text : `${text}Z`) + return Number.isNaN(ms) ? null : ms +} + +/** + * The rail's headings, oldest last. + * + * Ages are measured in whole local days rather than in elapsed hours because + * that is how the reader counts them: a chat from eleven last night is + * yesterday's, not "twelve hours ago". + */ +export function groupByAge(threads, now = Date.now()) { + const midnight = new Date(now) + midnight.setHours(0, 0, 0, 0) + const today = midnight.getTime() + const buckets = new Map([['Today', []], ['Previous 7 days', []], + ['Previous 30 days', []], ['Older', []]]) + for (const thread of threads) { + const at = asTime(thread.updated_at) + // A thread with no timestamp is one this page created a moment ago and has + // not heard back about yet, so it belongs at the top with the rest of today. + let label = 'Today' + if (at !== null && at < today) { + if (at >= today - 6 * DAY) label = 'Previous 7 days' + else if (at >= today - 29 * DAY) label = 'Previous 30 days' + else label = 'Older' + } + buckets.get(label).push(thread) + } + return [...buckets].filter(([, rows]) => rows.length).map(([label, rows]) => ({ label, threads: rows })) +} + +/** + * Openers for someone who has not asked anything yet. + * + * Every one of them is a question about a topic, because a topic is what + * retrieval can find in this learner's own articles, questions and cards. + * Nothing here asks the chat to write, upload, mark or remember anything: it + * cannot, and an opener that fails is worse than no opener at all. + */ +const STARTERS = [ + 'What does my library say about managing bronchiolitis?', + 'Explain the difference between Kawasaki disease and scarlet fever', + 'Walk me through fluid management in paediatric dehydration', + 'What are the red flags in a limping child?', + 'Summarise the causes of failure to thrive in an infant', + 'How is asthma severity graded in children?', + 'What does my library cover on neonatal jaundice?', + 'Which vaccines are due at the twelve-month visit?', +] +// Four is enough to show what the box is for; the rest are there for anyone +// still deciding. +const STARTERS_SHOWN = 4 + /** * Turn the markers left in the prose into numbered links. * @@ -73,6 +140,11 @@ function Answer({ content, citations, onPractise, practising }) { * what people already know; what is different is underneath. Retrieval decides * what the model may cite, the server deletes anything else, and the answer * carries its sources so a claim can be checked rather than believed. + * + * The page is also a destination for a question asked somewhere else: `?ask=` + * in the address is a question handed over by the search overlay, and it is + * opened in a thread of its own and answered without the learner having to type + * it a second time. */ export default function AiModePage() { const [threads, setThreads] = useState([]) @@ -82,11 +154,28 @@ export default function AiModePage() { const [sending, setSending] = useState(false) const [loading, setLoading] = useState(true) const [error, setError] = useState('') + // 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) + 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) const endRef = useRef(null) const navigate = useNavigate() + const [searchParams, setSearchParams] = useSearchParams() + const incoming = searchParams.get('ask') + // Whether the question now in the address has already been sent. It is not a + // piece of state because nothing on screen depends on it, and a render caused + // by setting it would be a render in which the question could be sent again. + const consumed = useRef(false) + // Fixed on the first render: a page opened with a question in it opens a + // thread of its own for the answer, and the thread list must not quietly open + // the most recent chat underneath it. + const fromUrl = useRef(Boolean(incoming)) + // The thread whose first answer is still in flight. Its transcript lives only + // in this tab so far, so fetching it would wipe the question sitting in it. + const inFlight = useRef(null) // Speaking instead of typing. The browser's own recogniser where there is // one, our transcriber where there is not. const dictation = useDictation({ @@ -100,13 +189,14 @@ export default function AiModePage() { useEffect(() => { loadThreads().then(rows => { - if (rows.length) setActiveId(rows[0].id) + if (rows.length && !fromUrl.current) setActiveId(rows[0].id) setLoading(false) }) }, [loadThreads]) useEffect(() => { if (activeId == null) { setMessages([]); return } + if (inFlight.current === activeId) return api.get(`/ai/conversations/${activeId}`) .then(res => setMessages(res.data.messages || [])) .catch(() => setError('Could not open that conversation')) @@ -114,11 +204,13 @@ export default function AiModePage() { useEffect(() => { endRef.current?.scrollIntoView?.({ behavior: 'smooth' }) }, [messages, sending]) + const groups = useMemo(() => groupByAge(threads), [threads]) + const startThread = async () => { setError('') try { const res = await api.post('/ai/conversations') - setThreads(prev => [{ id: res.data.id, title: res.data.title, message_count: 0 }, ...prev]) + setThreads(prev => [{ id: res.data.id, title: res.data.title, message_count: 0, updated_at: null }, ...prev]) setActiveId(res.data.id) setMessages([]) setRailOpen(false) @@ -149,73 +241,123 @@ export default function AiModePage() { } } - const send = async (event) => { - event?.preventDefault?.() - const text = draft.trim() - if (!text || sending) return + /** + * Ask, from wherever the question came from — the box, a starting prompt, or + * the address bar. `fresh` forces a thread of its own, which is what a + * question handed over from elsewhere deserves: it has nothing to do with + * whatever chat happened to be open. + */ + const ask = async (text, { fresh = false } = {}) => { + const question = (text ?? '').trim() + if (!question || sending) return - let threadId = activeId + let threadId = fresh ? null : activeId setError('') setSending(true) // The question appears immediately; waiting on a round trip to see your own // words makes the whole thing feel broken. - setMessages(prev => [...prev, { id: `pending-${Date.now()}`, role: 'user', content: text, citations: [] }]) + const pending = { id: `pending-${Date.now()}`, role: 'user', content: question, citations: [] } + setMessages(prev => (fresh ? [pending] : [...prev, pending])) setDraft('') try { if (threadId == null) { const created = await api.post('/ai/conversations') threadId = created.data.id + inFlight.current = threadId setActiveId(threadId) - setThreads(prev => [{ id: threadId, title: 'New chat', message_count: 0 }, ...prev]) + setThreads(prev => [{ id: threadId, title: 'New chat', message_count: 0, updated_at: null }, ...prev]) } - const res = await api.post(`/ai/conversations/${threadId}/messages`, { message: text }) + const res = await api.post(`/ai/conversations/${threadId}/messages`, { message: question }) setMessages(prev => [...prev, res.data.message]) setThreads(prev => prev.map(t => t.id === threadId ? { ...t, title: res.data.title } : t)) } catch (err) { setError(apiError(err, 'AI Mode is unavailable right now')) setMessages(prev => prev.filter(m => !String(m.id).startsWith('pending-'))) - setDraft(text) // Handing the question back rather than losing it. - } finally { setSending(false) } + setDraft(question) // Handing the question back rather than losing it. + } finally { + inFlight.current = null + setSending(false) + } } + // `ask` reads state that changes on every render, so the mount effect below + // reaches it through a ref rather than through a copy taken on the first + // render. Declared before that effect so it is already current when it runs. + const askRef = useRef(ask) + useEffect(() => { askRef.current = ask }) + + useEffect(() => { + if (!incoming) { + // The question has gone from the address, so the next one to appear there + // is a new one — including the same words asked a second time. + consumed.current = false + return + } + // Marked before the request goes out, so that the renders between here and + // the address being rewritten cannot send it again. + if (consumed.current) return + consumed.current = true + // Taken out of the address, replacing rather than pushing, so that a + // refresh does not ask it a second time and Back does not either. + setSearchParams(prev => { + const next = new URLSearchParams(prev) + next.delete('ask') + return next + }, { replace: true }) + askRef.current(incoming, { fresh: true }) + }, [incoming, setSearchParams]) + + const submit = (event) => { + event?.preventDefault?.() + ask(draft) + } + + const blank = messages.length === 0 && !sending + const starters = moreStarters ? STARTERS : STARTERS.slice(0, STARTERS_SHOWN) + return ( -
+
-
- {messages.length === 0 && !sending ? ( -
-

AI Mode

-

- Ask about anything in your library. Answers come from your own reading, - questions and cards — and every claim carries the source it came from, - so you can check it rather than take its word. -

-

- If your library does not cover something, it says so instead of - filling the gap with something you cannot verify. -

-
+
+ {blank ? ( +

How can PedsHub help you today?

) : (
{messages.map(message => ( @@ -238,24 +380,54 @@ export default function AiModePage() { {error &&

{error}

} -
-