feat: one box that either searches the corpus or asks the model

Ctrl+K anywhere. 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. It 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.

Search keeps its furniture: suggestions, the history of what was asked,
and the keys spelled out. Space fills the box with a suggestion rather
than following it, so a near-miss is edited instead of retyped. AI Mode
drops all of that — a list of half-remembered searches is not help while
writing a question — and gains a clear button and a submit arrow.

Submitting in AI Mode does not show results. It hands the question to the
conversation at /ai?ask=, which is where the follow-up will be asked from
anyway.

The header's search box becomes the door to it rather than a second box
beside it: two fields that do nearly the same thing is how people learn
to trust neither.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 06:56:34 +02:00
parent 2b5262d255
commit 5f121533e9
8 changed files with 412 additions and 13 deletions

View file

@ -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.

View file

@ -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. */}
<ChooseObjective />
<Navbar />
{/* 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. */}
<SearchOverlay open={searching} onClose={() => setSearching(false)} />
<Navbar onSearch={() => setSearching(true)} />
<div className="container app-main">
<ErrorBoundary key={location.pathname}>
<Outlet />

View file

@ -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);
}

View file

@ -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 (
<div className="gs" ref={box}>
<input className="gs-input" value={query} role="searchbox" aria-label="Search PedsHub"
placeholder="Search PedsHub" autoComplete="off"
onChange={e => { 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 ? (
<button type="button" className="gs-input gs-open" onClick={onOpenOverlay}>
<span>Search PedsHub</span>
<kbd>Ctrl K</kbd>
</button>
) : (
<input className="gs-input" value={query} role="searchbox" aria-label="Search PedsHub"
placeholder="Search PedsHub" autoComplete="off"
onChange={e => { setQuery(e.target.value); setOpen(true) }}
onFocus={() => setOpen(true)} onKeyDown={onKeyDown} />
)}
{!onOpenOverlay && open && query.trim().length >= 2 && (
<ul className="gs-menu" role="listbox">
{items.map((item, index) => (
<li key={`${item.kind}-${item.id}`}>

View file

@ -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 }) {
<div className="navbar-primary">
<div className="container navbar-inner">
<Link to="/" className="logo" onClick={() => setMenuOpen(false)}>🏥 PedsHub</Link>
{user && <GlobalSearch />}
{user && <GlobalSearch onOpenOverlay={onSearch} />}
{user ? (
<div className="navbar-account">

View file

@ -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; }
}

View file

@ -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 (
<div className="so-overlay" onMouseDown={e => 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. */}
<div className="so-panel" role="dialog" aria-modal="true"
aria-label={asking ? 'AI Mode' : 'Search'}>
<div className="so-head">
<div className="so-tabs" role="tablist" aria-label="Search or ask">
<button type="button" role="tab" aria-selected={!asking}
onClick={() => setTab('search')}>Search</button>
<button type="button" role="tab" aria-selected={asking}
onClick={() => setTab('ai')}>AI Mode</button>
</div>
{/* Only in AI Mode: what the assistant is being asked to be is not a
question the corpus has an answer to. */}
{asking && (
<label className="so-mode">
<span className="sr-only">What to ask it as</span>
<select defaultValue="learning">
{MODES.map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
</label>
)}
</div>
<div className="so-field">
<input ref={field} value={query} type="search" autoComplete="off"
aria-label={asking ? 'Ask AI Mode' : 'Search PedsHub'}
placeholder={asking ? 'Ask about anything in your library…' : 'Search PedsHub'}
onChange={e => setQuery(e.target.value)} onKeyDown={onKeyDown} />
{asking && query && (
<button type="button" className="so-clear" aria-label="Clear"
onClick={() => { setQuery(''); field.current?.focus() }}></button>
)}
{asking && (
<button type="button" className="so-send" aria-label="Ask" disabled={!query.trim()}
onClick={submit}></button>
)}
</div>
{!asking && items.length > 0 && (
<ul className="so-list" role="listbox">
{items.map((item, index) => (
<li key={`${item.kind}-${item.id}`}>
<button type="button" role="option" aria-selected={index === active}
className={index === active ? 'is-active' : ''}
onMouseEnter={() => setActive(index)}
onClick={() => go(`/articles/${item.id}`, query.trim())}>
<span className="so-kind">Reading</span>
<span>{item.title}</span>
</button>
</li>
))}
</ul>
)}
{/* 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 && (
<>
<p className="so-heading">Search history</p>
<ul className="so-history">
{history.map(past => (
<li key={past}>
<button type="button" onClick={() => setQuery(past)}>{past}</button>
</li>
))}
</ul>
</>
)}
{!asking && (
<ul className="so-keys">
{hint.map(([key, what]) => (
<li key={key}><kbd>{key}</kbd> {what}</li>
))}
</ul>
)}
</div>
</div>
)
}

View file

@ -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(
<MemoryRouter><SearchOverlay open={open} onClose={vi.fn()} /></MemoryRouter>)
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(<MemoryRouter><SearchOverlay open onClose={onClose} /></MemoryRouter>)
await userEvent.type(screen.getByLabelText('Search PedsHub'), 'febrile')
await userEvent.keyboard('{Escape}')
expect(onClose).toHaveBeenCalled()
expect(navigate).not.toHaveBeenCalled()
})