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 }) {
    setMenuOpen(false)}>🏥 PedsHub - {user && } + {user && } {user ? (
    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 && ( + + )} +
    + +
    + setQuery(e.target.value)} onKeyDown={onKeyDown} /> + {asking && query && ( + + )} + {asking && ( + + )} +
    + + {!asking && items.length > 0 && ( +
      + {items.map((item, index) => ( +
    • + +
    • + ))} +
    + )} + + {/* 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() +})