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:
parent
2b5262d255
commit
5f121533e9
8 changed files with 412 additions and 13 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`}>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
77
frontend/src/components/SearchOverlay.css
Normal file
77
frontend/src/components/SearchOverlay.css
Normal 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; }
|
||||
}
|
||||
194
frontend/src/components/SearchOverlay.jsx
Normal file
194
frontend/src/components/SearchOverlay.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
frontend/src/components/SearchOverlay.test.jsx
Normal file
86
frontend/src/components/SearchOverlay.test.jsx
Normal 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()
|
||||
})
|
||||
Loading…
Reference in a new issue