What it enables
The sources list counts documents, not chunks. Retrieval returns chunks, so a document that answers a question in three places returned three of them. Each became its own card — identical title, identicalscope · sourceType subtitle
— because the only thing distinguishing them was text the panel never showed.
An answer citing three documents reported “SOURCES (6)” and looked broken.
Chunks are now folded per document, ranked by their best chunk score, with a
×3 badge when several passages hit.
A row shows the passage that matched, with the question’s words emphasised.
The first attempt showed each chunk’s section heading instead — but most
documents in this hub open with an H1 equal to their frontmatter title, so the
“what matched” line was the row’s own title restated. The matched text was in
the retrieval payload all along and never rendered. Now a row carries a
~180-character snippet windowed around the first query match, and a heading is
only shown when it says something the title doesn’t.
A follow-up can run on a different model than the answer above it — the
point of asking again is often “try that on something stronger”. The modal used
to send a hardcoded preferred_model: "qwen3:14b", a local Ollama model that no
longer exists in config/model_catalog.yaml, so every follow-up pinned a dead
id and paid for the resolver’s fallback hop. It now carries the same model and
effort pickers as the chat composers, inside the input box at its bottom-right,
and preferred_provider travels with the id.
The header names the model in product terms — ”· Claude Haiku 4.5” behind
its provider mark, rather than the raw claude-haiku-4-5-20251001.
What’s changed
apps/frontend/lib/knowledge/group-citations.ts(new) —groupCitations()folds chunks per document;queryTerms()andsnippetSegments()build the highlighted snippet. Pure, and inlib/rather than beside the component because the vitest environment isnode— a component test is not possible here, a helper test is.apps/frontend/lib/knowledge/group-citations.test.ts(new) — 25 cases.apps/frontend/components/layout/footer/task-output-modal.tsx— theCitationsPanelrows, aSnippetTextrenderer, and the follow-up model / effort pickers (components/ui/icon-select.tsx).
Highlight matching, and why it is the way it is
Two anchors, each earning its keep:- No leading
\band “use” highlights the middle of “because”. - No trailing
\band a prefix match truncates mid-word. This one shipped briefly: to make a singular query match a plural in the text, a trailingswas stripped from the term — so asking about “Amos” lit up “Amo” and left the final letter plain, which reads as a rendering bug.
model(?:es|s)?) rather than by shortening the term, and both ends are
anchored. A short stopword list covers the words people phrase questions with
(“in one sentence — what models does Amos use?”) which would otherwise light up
half the snippet.
A portaled menu inside a hand-rolled click-away
Putting the pickers in this modal broke it: choosing any model closed the modal instantly. Read this before adding another portaled layer anywhere inside the command widget. A floating layer renders intodocument.body, not into the React parent that
owns it. command-widget.tsx closes its open panel from a document mousedown
listener guarded by containerRef.current.contains(e.target) — the standard
click-away test — and a menu item is not a DOM descendant of that container.
So pressing a model was a click-away, and dismiss() unmounted the modal on
mousedown, before the selection even resolved.
lib/ui/floating-layer.ts holds the two predicates that fix it:
isInsideFloatingLayer(target)— bail out of a click-away handler. A press on a menu item is a press inside the UI that opened the menu, wherever the DOM put it.hasOpenFloatingLayer()— bail out of an Escape handler, so one press closes one thing: the menu, then the panel behind it on the next press. Both this modal and the widget had the same Escape flaw.
data-radix-popper-content-wrapper, the attribute
@radix-ui/react-popper puts on the wrapper it renders around every
popper-based layer — verified against the installed @radix-ui/react-menu,
which renders its content through PopperPrimitive.Content and carries
data-state on it.
The Escape predicate depends on listener order and says so in its doc comment:
the layer mounts after the panel, so Radix’s own keydown listener registers
later and ours runs first, while the menu is still open. A handler running
after Radix’s would find the menu already gone.
Note this only bites non-modal layers. IconSelect sets modal={false}
deliberately (a toolbar control must not aria-hide the app or lock scroll),
which leaves outside pointer events live. Radix Select is modal by default
and suppresses them, which is why the app’s other hand-rolled click-aways
never hit this with their Selects.
Impact scope
- Confined to the task output modal and one new
lib/module. No API, schema, env var or infrastructure change;/api/kb/querystill requeststop_k=6and its payload is unchanged —contentandsection_titlewere already in it. - The visible source count drops (6 chunks → 3 documents). That is the fix, but
it does mean the number no longer matches
top_k. - The skeleton still renders six placeholder rows while loading, so the panel briefly reserves more rows than it will show. Harmless, and preferable to reflowing upward.
- Follow-up requests change shape: no
preferred_modelat all on Auto, andpreferred_model+preferred_providertogether when pinned.
Tests
kh_id, whole-word and plural matching, the “because”/“among” false positives,
windowing around a deep match, a query of nothing but stopwords, regex
characters in the query, and segment offsets indexing the rendered string.
Pre-existing lint failures elsewhere, unrelated to this work: app/icon.svg
(a11y/noSvgWithoutTitle) and lib/voice/stt/server.ts (formatter).
Manual — the vitest environment is node with no jsdom, so none of the
following is reachable from a test:
- Ask a
quick_qaquestion. The source count equals the number of distinct documents, and each row’s snippet contains the question’s words emphasised. - Open the model dropdown and pick something. The modal must stay open. This is the retarget bug above; nothing in the type-checker or the suite catches it.
- Click the backdrop — the modal closes. Press inside the panel and release on the backdrop — it does not.
- Pick a different model for a follow-up and send: the next answer’s header names that model.