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. 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 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 corpus or asks the model, and which one is a toggle rather than two
separate destinations. Below the field: SEARCH HISTORY, the previous separate destinations. Below the field: SEARCH HISTORY, the previous
queries, and the keys spelled out — `Ctrl+K` open, `↑↓` navigate queries, and the keys spelled out — `Ctrl+K` open, `↑↓` navigate
suggestions, `Space` use a suggestion, `Enter` submit. Opens from suggestions, `Space` use a suggestion, `Enter` submit. Opens from
anywhere with Ctrl+K. 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 what the same field does: the history and the key hints go, a mode
picker appears beside the tabs ("Learning ⌄" — what the assistant is picker appears beside the tabs ("Learning ⌄" — what the assistant is
being asked to be), a clear button appears in the field once there 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 text, and the submit arrow fills in. The panel's own title tracks the
chosen mode. 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 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 chat with that question already asked and being answered — not to a
results list, and not back to an empty box. 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 { BrowserRouter, Routes, Route, Navigate, Outlet, Link, useLocation, useParams } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext' import { AuthProvider, useAuth } from './context/AuthContext'
import { SessionDrawerProvider } from './context/SessionDrawer' import { SessionDrawerProvider } from './context/SessionDrawer'
@ -6,6 +6,7 @@ import { ThemeProvider } from './context/ThemeContext'
import Navbar from './components/Navbar' import Navbar from './components/Navbar'
import SiteFooter from './components/SiteFooter' import SiteFooter from './components/SiteFooter'
import ChooseObjective from './components/ChooseObjective' import ChooseObjective from './components/ChooseObjective'
import SearchOverlay from './components/SearchOverlay'
import ErrorBoundary from './components/ErrorBoundary' import ErrorBoundary from './components/ErrorBoundary'
import lazyPage from './utils/lazyPage' import lazyPage from './utils/lazyPage'
@ -61,6 +62,22 @@ function LoadingFallback() {
function AppLayout() { function AppLayout() {
// Keyed by path so navigating away from a broken page clears the error. // Keyed by path so navigating away from a broken page clears the error.
const location = useLocation() 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 ( return (
/* A column the height of the window, so the footer is at the bottom of the /* 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 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 means the whole bank, which is a reasonable default and a poor thing
to arrive at without being asked. */} to arrive at without being asked. */}
<ChooseObjective /> <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"> <div className="container app-main">
<ErrorBoundary key={location.pathname}> <ErrorBoundary key={location.pathname}>
<Outlet /> <Outlet />

View file

@ -36,3 +36,14 @@
@media (max-width: 900px) { @media (max-width: 900px) {
.gs { display: none; } .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 * 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. * 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 [query, setQuery] = useState('')
const [items, setItems] = useState([]) const [items, setItems] = useState([])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@ -52,11 +52,21 @@ export default function GlobalSearch() {
return ( return (
<div className="gs" ref={box}> <div className="gs" ref={box}>
<input className="gs-input" value={query} role="searchbox" aria-label="Search PedsHub" {/* Where an overlay is on offer, this is the door to it rather than a
placeholder="Search PedsHub" autoComplete="off" second search box beside it two boxes that do nearly the same thing
onChange={e => { setQuery(e.target.value); setOpen(true) }} is how people learn to trust neither. */}
onFocus={() => setOpen(true)} onKeyDown={onKeyDown} /> {onOpenOverlay ? (
{open && query.trim().length >= 2 && ( <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"> <ul className="gs-menu" role="listbox">
{items.map((item, index) => ( {items.map((item, index) => (
<li key={`${item.kind}-${item.id}`}> <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 { user, logout } = useAuth()
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
// The section bar is wanted when you decide to go elsewhere and in the way // 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="navbar-primary">
<div className="container navbar-inner"> <div className="container navbar-inner">
<Link to="/" className="logo" onClick={() => setMenuOpen(false)}>🏥 PedsHub</Link> <Link to="/" className="logo" onClick={() => setMenuOpen(false)}>🏥 PedsHub</Link>
{user && <GlobalSearch />} {user && <GlobalSearch onOpenOverlay={onSearch} />}
{user ? ( {user ? (
<div className="navbar-account"> <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()
})