diff --git a/docs/TODO.md b/docs/TODO.md
index 86ffec4..50e54f4 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -397,20 +397,20 @@ and nothing reaches the bank until an administrator has read it.
Two screenshots, one flow.
-- [ ] **The search overlay.** A panel that opens over whatever you are on, with
+- [x] **The search overlay** — done 2026-09-12. A panel that opens over whatever you are on, with
a *Search* / *AI Mode* pair of tabs at the top — the same box asks the
corpus or asks the model, and which one is a toggle rather than two
separate destinations. Below the field: SEARCH HISTORY, the previous
queries, and the keys spelled out — `Ctrl+K` open, `↑↓` navigate
suggestions, `Space` use a suggestion, `Enter` submit. Opens from
anywhere with Ctrl+K.
-- [ ] **The overlay in AI Mode.** Switching the toggle to *AI Mode* changes
+- [x] **The overlay in AI Mode** — done 2026-09-12. Switching the toggle to *AI Mode* changes
what the same field does: the history and the key hints go, a mode
picker appears beside the tabs ("Learning ⌄" — what the assistant is
being asked to be), a clear button appears in the field once there is
text, and the submit arrow fills in. The panel's own title tracks the
chosen mode.
-- [ ] **Submitting from AI Mode lands in the conversation.** The overlay is
+- [x] **Submitting from AI Mode lands in the conversation** — done 2026-09-12, as `/ai?ask=`. The overlay is
only where the question is typed: pressing submit goes to the AI Mode
chat with that question already asked and being answered — not to a
results list, and not back to an empty box.
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 0d38240..31b5b0c 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,4 +1,4 @@
-import { Suspense } from 'react'
+import { Suspense, useEffect, useState } from 'react'
import { BrowserRouter, Routes, Route, Navigate, Outlet, Link, useLocation, useParams } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import { SessionDrawerProvider } from './context/SessionDrawer'
@@ -6,6 +6,7 @@ import { ThemeProvider } from './context/ThemeContext'
import Navbar from './components/Navbar'
import SiteFooter from './components/SiteFooter'
import ChooseObjective from './components/ChooseObjective'
+import SearchOverlay from './components/SearchOverlay'
import ErrorBoundary from './components/ErrorBoundary'
import lazyPage from './utils/lazyPage'
@@ -61,6 +62,22 @@ function LoadingFallback() {
function AppLayout() {
// Keyed by path so navigating away from a broken page clears the error.
const location = useLocation()
+ const [searching, setSearching] = useState(false)
+
+ useEffect(() => {
+ const onKey = (event) => {
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
+ // Taken from the browser deliberately: Ctrl+K is what every tool this
+ // sits beside uses, and a learner who has learned it once should not
+ // have to learn ours.
+ event.preventDefault()
+ setSearching(true)
+ }
+ }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [])
+
return (
/* A column the height of the window, so the footer is at the bottom of the
screen rather than at the bottom of the content — a page still loading
@@ -71,7 +88,11 @@ function AppLayout() {
means the whole bank, which is a reasonable default and a poor thing
to arrive at without being asked. */}
-
+ {/* Over whatever is on screen: a question arrives while you are reading
+ something, and having to leave that page to ask is how it gets
+ dropped. */}
+ setSearching(false)} />
+ setSearching(true)} />
diff --git a/frontend/src/components/GlobalSearch.css b/frontend/src/components/GlobalSearch.css
index 0df4c39..4b9a97b 100644
--- a/frontend/src/components/GlobalSearch.css
+++ b/frontend/src/components/GlobalSearch.css
@@ -36,3 +36,14 @@
@media (max-width: 900px) {
.gs { display: none; }
}
+
+/* The door to the overlay. It looks like the field it replaces, because that
+ is where people already reach for it. */
+.gs-open {
+ display: flex; align-items: center; justify-content: space-between; gap: 10px;
+ cursor: pointer; text-align: left; color: var(--text-subtle);
+}
+.gs-open kbd {
+ padding: 2px 6px; border-radius: 5px; font: inherit; font-size: 0.68rem;
+ background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.18);
+}
diff --git a/frontend/src/components/GlobalSearch.jsx b/frontend/src/components/GlobalSearch.jsx
index 1e30d51..b44954e 100644
--- a/frontend/src/components/GlobalSearch.jsx
+++ b/frontend/src/components/GlobalSearch.jsx
@@ -13,7 +13,7 @@ import './GlobalSearch.css'
* Suggestions are lexical and prefix-first, because a typeahead is finishing the
* word you are typing; a semantic neighbour of half a word is noise.
*/
-export default function GlobalSearch() {
+export default function GlobalSearch({ onOpenOverlay }) {
const [query, setQuery] = useState('')
const [items, setItems] = useState([])
const [open, setOpen] = useState(false)
@@ -52,11 +52,21 @@ export default function GlobalSearch() {
return (
- { setQuery(e.target.value); setOpen(true) }}
- onFocus={() => setOpen(true)} onKeyDown={onKeyDown} />
- {open && query.trim().length >= 2 && (
+ {/* Where an overlay is on offer, this is the door to it rather than a
+ second search box beside it — two boxes that do nearly the same thing
+ is how people learn to trust neither. */}
+ {onOpenOverlay ? (
+
+ ) : (
+ { setQuery(e.target.value); setOpen(true) }}
+ onFocus={() => setOpen(true)} onKeyDown={onKeyDown} />
+ )}
+ {!onOpenOverlay && open && query.trim().length >= 2 && (
{items.map((item, index) => (
diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx
index 4bf5b47..066a0dd 100644
--- a/frontend/src/components/Navbar.jsx
+++ b/frontend/src/components/Navbar.jsx
@@ -180,7 +180,7 @@ function AccountMenu({ user, onLogout }) {
}
-export default function Navbar({ onSignIn, onRegister }) {
+export default function Navbar({ onSignIn, onRegister, onSearch }) {
const { user, logout } = useAuth()
const [menuOpen, setMenuOpen] = useState(false)
// The section bar is wanted when you decide to go elsewhere and in the way
@@ -254,7 +254,7 @@ export default function Navbar({ onSignIn, onRegister }) {
diff --git a/frontend/src/components/SearchOverlay.css b/frontend/src/components/SearchOverlay.css
new file mode 100644
index 0000000..98bfd33
--- /dev/null
+++ b/frontend/src/components/SearchOverlay.css
@@ -0,0 +1,77 @@
+.so-overlay {
+ position: fixed; inset: 0; z-index: 150;
+ display: flex; justify-content: center; align-items: flex-start;
+ padding: 12vh 20px 20px; background: rgba(15, 23, 42, 0.5);
+}
+.so-panel {
+ width: min(620px, 100%); max-height: 76vh; overflow-y: auto;
+ padding: 16px; border-radius: 16px;
+ background: var(--card-bg); border: 1px solid var(--border);
+ box-shadow: 0 26px 70px rgba(15, 23, 42, 0.32);
+}
+
+.so-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 12px; }
+.so-tabs { display: flex; gap: 4px; padding: 3px; border-radius: 10px; background: var(--bg); }
+.so-tabs button {
+ border: 0; background: none; color: var(--text-muted); cursor: pointer;
+ font: inherit; font-size: 0.86rem; padding: 7px 14px; border-radius: 8px;
+}
+.so-tabs button[aria-selected='true'] { background: var(--card-bg); color: var(--primary); font-weight: 650; }
+.so-mode select {
+ padding: 6px 10px; font: inherit; font-size: 0.82rem;
+ border: 1px solid var(--border); border-radius: 8px;
+ background: var(--input-bg); color: var(--text);
+}
+
+.so-field { position: relative; display: flex; align-items: center; }
+.so-field input {
+ width: 100%; padding: 13px 82px 13px 14px; font: inherit; font-size: 1rem;
+ border: 1px solid var(--border); border-radius: 11px;
+ background: var(--input-bg); color: var(--text);
+}
+.so-field input:focus { outline: 2px solid var(--primary); outline-offset: 1px; }
+.so-clear, .so-send {
+ position: absolute; border: 0; cursor: pointer; border-radius: 8px;
+ background: none; color: var(--text-muted);
+}
+.so-clear { right: 46px; width: 30px; height: 30px; font-size: 0.85rem; }
+.so-send {
+ right: 8px; width: 34px; height: 34px; font-size: 1.05rem;
+ background: var(--primary); color: var(--primary-fg);
+}
+.so-send:disabled { opacity: 0.45; cursor: default; }
+
+.so-list, .so-history { list-style: none; margin: 12px 0 0; padding: 0; }
+.so-list button, .so-history button {
+ display: flex; align-items: center; gap: 10px; width: 100%;
+ padding: 10px 12px; text-align: left; cursor: pointer;
+ font: inherit; font-size: 0.88rem; color: var(--text);
+ background: none; border: 0; border-radius: 9px;
+}
+.so-list button:hover, .so-list button.is-active, .so-history button:hover { background: var(--bg); }
+.so-kind {
+ flex-shrink: 0; padding: 2px 8px; border-radius: 20px;
+ font-size: 0.66rem; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase;
+ background: var(--bg); color: var(--text-subtle);
+}
+.so-heading {
+ margin: 16px 0 4px; font-size: 0.66rem; font-weight: 700;
+ letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-subtle);
+}
+.so-history button { color: var(--text-muted); }
+
+.so-keys {
+ list-style: none; display: flex; flex-wrap: wrap; gap: 14px;
+ margin: 16px 0 0; padding: 12px 2px 0; border-top: 1px solid var(--border);
+ font-size: 0.74rem; color: var(--text-subtle);
+}
+.so-keys kbd {
+ padding: 2px 6px; margin-right: 3px; border-radius: 5px;
+ font: inherit; font-size: 0.7rem;
+ background: var(--bg); border: 1px solid var(--border);
+}
+
+@media (max-width: 560px) {
+ .so-overlay { padding: 6vh 12px 12px; }
+ .so-keys { display: none; }
+}
diff --git a/frontend/src/components/SearchOverlay.jsx b/frontend/src/components/SearchOverlay.jsx
new file mode 100644
index 0000000..1ec5207
--- /dev/null
+++ b/frontend/src/components/SearchOverlay.jsx
@@ -0,0 +1,194 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import api from '../api/client'
+import './SearchOverlay.css'
+
+const HISTORY_KEY = 'pedshub.searchHistory'
+const HISTORY_MAX = 6
+//: What the assistant is being asked to be. Learning is the only one this
+//: product answers as; the rest of the list would be a promise it cannot keep.
+const MODES = [['learning', 'Learning']]
+
+/** A per-browser convenience. If it is unreadable the panel is simply shorter. */
+function readHistory() {
+ try { return JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]').filter(s => typeof s === 'string') }
+ catch { return [] }
+}
+
+function remember(query) {
+ try {
+ const next = [query, ...readHistory().filter(q => q !== query)].slice(0, HISTORY_MAX)
+ localStorage.setItem(HISTORY_KEY, JSON.stringify(next))
+ } catch { /* private browsing, or storage turned off */ }
+}
+
+/**
+ * One box that either asks the corpus or asks the model.
+ *
+ * Searching and asking are the same act from where the learner sits — they have
+ * a question and want it answered — so which one happens is a toggle rather
+ * than two destinations to choose between before typing. The panel opens over
+ * whatever is on screen, because a question arrives while you are reading
+ * something, and having to leave that page to ask is how the question gets
+ * dropped.
+ *
+ * Submitting in AI Mode does not show results. It hands the question to the
+ * conversation and lets the answer arrive there, which is where the follow-up
+ * will be asked from anyway.
+ */
+export default function SearchOverlay({ open, onClose }) {
+ const [tab, setTab] = useState('search')
+ const [query, setQuery] = useState('')
+ const [items, setItems] = useState([])
+ const [active, setActive] = useState(-1)
+ const [history, setHistory] = useState(readHistory)
+ const field = useRef(null)
+ const navigate = useNavigate()
+
+ useEffect(() => {
+ if (!open) return undefined
+ setHistory(readHistory())
+ // Focused on the next frame: the element does not exist until this render
+ // has been painted, and focusing a node that is not in the document does
+ // nothing at all.
+ const timer = requestAnimationFrame(() => field.current?.focus())
+ return () => cancelAnimationFrame(timer)
+ }, [open])
+
+ useEffect(() => {
+ if (!open || tab !== 'search' || query.trim().length < 2) { setItems([]); return undefined }
+ const timer = setTimeout(() => {
+ api.get('/search/suggest', { params: { q: query.trim() } })
+ .then(res => { setItems(res.data?.suggestions || []); setActive(-1) })
+ .catch(() => setItems([]))
+ }, 180) // A pause, not a keystroke: typing must not be a request each.
+ return () => clearTimeout(timer)
+ }, [open, tab, query])
+
+ const leave = useCallback(() => { setQuery(''); setItems([]); setActive(-1); onClose() }, [onClose])
+
+ const go = useCallback((path, remembered) => {
+ if (remembered) { remember(remembered); setHistory(readHistory()) }
+ leave()
+ navigate(path)
+ }, [leave, navigate])
+
+ const submit = useCallback(() => {
+ const text = query.trim()
+ if (!text) return
+ if (tab === 'ai') {
+ // The overlay is only where the question is typed. It is asked in the
+ // conversation, which is where the follow-up will be asked from.
+ go(`/ai?ask=${encodeURIComponent(text)}`, text)
+ return
+ }
+ if (active >= 0 && items[active]) { go(`/articles/${items[active].id}`, text); return }
+ go(`/search?q=${encodeURIComponent(text)}`, text)
+ }, [active, go, items, query, tab])
+
+ const onKeyDown = (event) => {
+ if (event.key === 'Escape') { event.preventDefault(); leave(); return }
+ if (event.key === 'Enter') { event.preventDefault(); submit(); return }
+ if (tab !== 'search' || !items.length) return
+ if (event.key === 'ArrowDown') { event.preventDefault(); setActive(i => Math.min(i + 1, items.length - 1)) }
+ else if (event.key === 'ArrowUp') { event.preventDefault(); setActive(i => Math.max(i - 1, -1)) }
+ else if (event.key === ' ' && active >= 0) {
+ // Space fills the box with the suggestion rather than following it, so a
+ // near-miss can be edited instead of retyped.
+ event.preventDefault()
+ setQuery(items[active].title)
+ setActive(-1)
+ }
+ }
+
+ const asking = tab === 'ai'
+ const hint = useMemo(() => ([
+ ['Ctrl+K', 'open'], ['↑↓', 'move'], ['Space', 'use'], ['Enter', 'go'],
+ ]), [])
+
+ if (!open) return null
+
+ return (
+
e.target === e.currentTarget && leave()}>
+ {/* The panel's name tracks the mode, and is distinct from the field's:
+ two things labelled the same are two things a screen reader reads
+ out identically. */}
+
+
+
+
+
+
+ {/* Only in AI Mode: what the assistant is being asked to be is not a
+ question the corpus has an answer to. */}
+ {asking && (
+
+ )}
+
+ )}
+
+ {/* History and the key hints are search's. In AI Mode the panel is a
+ place to write a question, and a list of half-remembered searches
+ underneath it is not help. */}
+ {!asking && history.length > 0 && query.trim().length < 2 && (
+ <>
+
Search history
+
+ {history.map(past => (
+
+
+
+ ))}
+
+ >
+ )}
+
+ {!asking && (
+
+ {hint.map(([key, what]) => (
+
{key} {what}
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/components/SearchOverlay.test.jsx b/frontend/src/components/SearchOverlay.test.jsx
new file mode 100644
index 0000000..0f8203f
--- /dev/null
+++ b/frontend/src/components/SearchOverlay.test.jsx
@@ -0,0 +1,86 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router-dom'
+import { beforeEach, expect, it, vi } from 'vitest'
+import SearchOverlay from './SearchOverlay'
+import api from '../api/client'
+
+vi.mock('../api/client', () => ({ default: { get: vi.fn() } }))
+
+const navigate = vi.fn()
+vi.mock('react-router-dom', async () => ({
+ ...await vi.importActual('react-router-dom'),
+ useNavigate: () => navigate,
+}))
+
+const SUGGESTIONS = [
+ { kind: 'article', id: 7, title: 'Febrile seizures' },
+ { kind: 'article', id: 8, title: 'Febrile neutropenia' },
+]
+
+const mount = (open = true) => render(
+ )
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ localStorage.clear()
+ api.get.mockResolvedValue({ data: { suggestions: SUGGESTIONS } })
+})
+
+it('is not on the page until it is opened', () => {
+ mount(false)
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+})
+
+it('searches the corpus and remembers what was asked', async () => {
+ mount()
+ await userEvent.type(screen.getByLabelText('Search PedsHub'), 'febrile')
+ await userEvent.keyboard('{Enter}')
+ expect(navigate).toHaveBeenCalledWith('/search?q=febrile')
+ // Asked once, offered back next time.
+ expect(JSON.parse(localStorage.getItem('pedshub.searchHistory'))).toEqual(['febrile'])
+})
+
+it('moves through suggestions, and Space edits one rather than following it', async () => {
+ mount()
+ const field = screen.getByLabelText('Search PedsHub')
+ await userEvent.type(field, 'febrile')
+ await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(2))
+
+ await userEvent.keyboard('{ArrowDown}')
+ expect(screen.getAllByRole('option')[0]).toHaveAttribute('aria-selected', 'true')
+
+ // Space fills the box so a near-miss can be edited instead of retyped.
+ await userEvent.keyboard(' ')
+ expect(field).toHaveValue('Febrile seizures')
+ expect(navigate).not.toHaveBeenCalled()
+})
+
+it('asks the model instead, and hands the question to the conversation', async () => {
+ mount()
+ await userEvent.click(screen.getByRole('tab', { name: 'AI Mode' }))
+ await userEvent.type(screen.getByLabelText('Ask AI Mode'), 'why is stridor extrathoracic')
+ await userEvent.click(screen.getByRole('button', { name: 'Ask' }))
+ // Not a results page: the question is asked where the follow-up will be.
+ expect(navigate).toHaveBeenCalledWith('/ai?ask=why%20is%20stridor%20extrathoracic')
+})
+
+it('drops the search furniture in AI Mode', async () => {
+ localStorage.setItem('pedshub.searchHistory', JSON.stringify(['febrile']))
+ mount()
+ expect(screen.getByText('Search history')).toBeInTheDocument()
+
+ await userEvent.click(screen.getByRole('tab', { name: 'AI Mode' }))
+ // A list of half-remembered searches is not help while writing a question.
+ expect(screen.queryByText('Search history')).not.toBeInTheDocument()
+ expect(screen.queryByText('open')).not.toBeInTheDocument()
+})
+
+it('closes on Escape without going anywhere', async () => {
+ const onClose = vi.fn()
+ render()
+ await userEvent.type(screen.getByLabelText('Search PedsHub'), 'febrile')
+ await userEvent.keyboard('{Escape}')
+ expect(onClose).toHaveBeenCalled()
+ expect(navigate).not.toHaveBeenCalled()
+})