feat(chat): chat_sources + inline citations (RAG answer grounding) - #643
Draft
mplatts wants to merge 2 commits into
Draft
feat(chat): chat_sources + inline citations (RAG answer grounding)#643mplatts wants to merge 2 commits into
mplatts wants to merge 2 commits into
Conversation
Adds the answer-grounding pattern to the chat family. Prompt the model to
cite with [^N] footnote markers, hand the same source maps to markdown/1,
and the markers become numbered chips; chat_sources/1 renders the deduped
source list underneath.
- citation/1: an inline chip. A real <a> (opens the source in a new tab
with no JS) carrying aria-label="Source N: {title}". The preview card is
pure CSS on :hover / :focus-within and marked aria-hidden, since its
content is already in the accessible name and the sources list.
- chat_sources/1: native <details>/<summary>, matching reasoning/1's
no-JS approach. Deduped by URL, `expanded` opens on render,
`max_visible` tucks the overflow behind a nested "Show all" reveal,
and a nil/empty list renders nothing at all.
- markdown/1 gains a `sources` attr; to_html/2 gains a `:sources` option
for the streaming path.
Marker splicing runs on the already-sanitized MDEx output, walking it as a
tag/text token stream so markers are only rewritten in text nodes, never
inside attributes or <pre>/<code>. Chip markup is minted from the numeric
index plus escaped source fields, so model-controlled text can never reach
the page as live markup. Only complete markers match, so a half-streamed
"[^" is left alone until its closing bracket arrives.
No new dependencies, no new JS hook, no changes to any existing attr
default or test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #643 +/- ##
==========================================
+ Coverage 92.40% 92.46% +0.06%
==========================================
Files 119 119
Lines 5066 5164 +98
==========================================
+ Hits 4681 4775 +94
- Misses 385 389 +4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This was referenced Aug 12, 2026
mplatts
added a commit
that referenced
this pull request
Aug 12, 2026
Member
Author
ScreenshotsCitation card and sources expanded Streaming Light / dark Verified independently
Stack position: base of the chat stack. Merge order is #643 → #644 → #646 → #618. Images live on the |
…rd, house focus ring Audit round on #615. (1) Source urls reached `href` unvalidated. Chips are spliced into the HTML *after* MDEx sanitizes it, so the sanitizer never sees them and a `javascript:` url from the retrieval layer became a live link in both the marker-splice path and `chat_sources`. `safe_url/1` is default-deny: only an explicit http/https scheme (after trimming, so whitespace-smuggled schemes fall through) reaches an href, and anything else still renders the chip and the row, just link-less. Tested in both paths. (2) The preview card was `pointer-events-none`, so it could be seen but never hovered - WCAG 1.4.13 wants that content hoverable. `visibility: hidden` already keeps the closed card out of hit-testing, so the reset was pure breakage; dropping it plus a 0.25rem bridge pseudo-element lets the pointer travel from chip to card to read a long snippet. The card was also a fixed `w-56` pinned `left-0`, which spilled a whole card's width past the right edge of a narrow bubble; it is now `w-max` centred on the chip and clamped to `min(14rem, 100vw - 2rem)`, so worst case is half a card and never wider than the viewport. Escape dismissal and flip-on-collision still need real positioning machinery - left alone deliberately, see the PR thread. (3) The playground explainer documented Tab/Enter and stayed quiet about dismissal, which read as if the brief's "Escape closes" had shipped. It now says plainly how the card dismisses, that it is hoverable, and that non-http urls render without a link. (4) Focus rings: these four rules used `outline-2 outline-offset-2 outline-primary-500`, which appears nowhere else in default.css - the library has ~36 uses of `ring-2 ring-primary-500/50`. Switched to the house idiom (the rest of the chat stack does the same in its own patches). (5) Two dead bits of CSS surface: `pc-chat__citation-outer` was emitted with no rule anywhere (the class attr now carries just the consumer's class), and `.pc-chat__citation-num { top: 0 }` did nothing on a statically positioned `<sup>`. (6) Coverage for two behaviours the brief lists that were already correct but unpinned: a `[^1]` inside inline backticks stays literal (only the fenced `<pre><code>` path was tested), and two markers on the same url keep their own chip numbers while `chat_sources` dedupes to one row. 944 tests, 0 failures, 1 skipped (940 before).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.





Closes #615
Stack position
1 of 4 in the 4.18.0 chat stack. This branch (
feat/chat-sources) is cut fromorigin/mainand is the base of the other three:feat/chat-sourcesmainfeat/chat-attachmentsfeat/chat-sourcesfeat/chat-questionnairefeat/chat-attachmentsfeat/chat-tool-callfeat/chat-questionnaireThe PR is opened against
mainbecause this one genuinely sits onmain, so the diff here is exactly this issue's work. Please merge in order 615 → 616 → 617 → 618 — the later PRs in the stack will show a cumulative diff until the ones below them land.Summary
Answer grounding for the chat family. Prompt the model to cite with
[^N]footnote markers, hand the same source maps tomarkdown/1, and the markers become numbered chips;chat_sources/1renders the deduped source list underneath.citation/1— the inline chip. Superscript number, preview card (favicon, title, domain, snippet) on hover or focus, activating it opens the source in a new tab.chat_sources/1— native<details>/<summary>, matchingreasoning/1's no-JS approach. Collapsed it reads "4 sources" with a stacked-favicon cluster. Deduped by URL,expandedopens it on render,max_visibletucks the overflow behind a nested "Show all (N)" reveal, and a nil/empty list renders nothing at all (no empty shell).markdown/1gains asourcesattr;to_html/2gains a:sourcesoption for the streaming path.Sources are plain maps —
%{id, url, title, snippet, favicon_url}, string or atom keys both accepted. Everything buturlis optional: nofavicon_urlfalls back to a letter avatar, nosnippetis just a shorter row.How the marker → chip transform works
Option (b) from the issue: post-process the sanitized HTML. MDEx's footnote extension is not enabled here, so
[^N]survives as literal text in the output, and splicing after sanitization means the chip markup can't be eaten by the sanitizer.The output is walked as a tag/text token stream, so markers are only ever rewritten inside text nodes — never inside an attribute value, and never inside
<pre>/<code>(a[^1]in a code sample stays a code sample). The chip markup is minted from the numeric index plusPhoenix.HTML.html_escaped source fields, so nothing model-controlled reaches the page as live markup. Only complete markers match, so a half-streamed[^is left untouched until its closing bracket arrives.A marker resolves to the source whose
idmatchesN, falling back to the Nth source, so an id-less list still works positionally.Deviations from the issue's sketch
The chip is an
<a>, not a<button>. The issue's a11y section says "real<button>s ... never bare<sup>text". The intent — a real interactive element carrying an accessible name — is met, but the action is "open this URL in a new tab", which is an anchor's job. As a<button>it would need JS to navigate; as an<a href target="_blank" rel="noopener noreferrer">it works with zero JS, gets middle-click/copy-link for free, and matchesexternal_links/1in the same file. Happy to flip it if you'd rather hold the line on<button>.No popover / hover_card machinery — the preview card is pure CSS.
hover_card/1hasn't landed, and composingPetalPopoverwould have meant a hook and an id-per-chip foraria-controls, on markup that is spliced into a raw HTML string with no assigns to generate ids from. The card is a sibling<span>shown on:hover/:focus-withinof the wrapper. This is the CSS-first house rule, and it keeps the "no new positioning system" non-goal honest. Trade-off: no flip/collision handling — the card is fixed above-left of the chip.The preview card is
aria-hidden="true". It is a visual affordance whose title and domain are already in the chip's accessible name, and whose snippet is reachable in thechat_sourceslist below. This avoids inventing ids foraria-describedbyinside spliced HTML and avoids a screen reader reading the same title twice per chip.to_html/2rather than a separate function —def to_html(content, opts \\ []), so existingto_html/1call sites are untouched.Backward compatibility
No existing test was modified or deleted, and no existing attr default changed. Every pre-existing chat test passes unmodified.
markdown/1withoutsources(the default,nil) is byte-identical to before — there's an explicit test for it ("without sources the marker passes through as plain text (today's behaviour)").to_html/1still exists and behaves exactly as it did (tested).citation/1andchat_sources/1are net-new function names.Chatis not pulled in byuse PetalComponents, so neither can collide with a consumer's helpers.@layer components { }block at the tail ofassets/default.css— nothing above it reflowed, and being inside a layer means consumer utilities passed viaclassstill win.Verification
mix format --check-formattedmix compile --force --warnings-as-errorsmix credoorigin/main, zero new entriesmix testnpm testPlayground
Extended the existing AI Chat page (slug
chat) — no new nav slug.[^N]markers, with thechat_sourcesrow underneath.to_html(buffer, sources: sources)and pushes it at aformat="markdown"streaming_text, so chips light up as their markers complete.sources expanded(closed / open) andmax_visible(2 / 5 / all).Verified light + dark, keyboard-only (Tab reaches a chip, the preview card opens on focus, Enter opens the source), and the streaming demo end to end.
Accessibility
aria-label="Source N: {title}"— the number alone is never the accessible name.target="_blank"+rel="noopener noreferrer".alt="",aria-hidden="true",loading="lazy").<summary>semantics — no ARIA needed.focus-visiblerings in primary on the chip, the summary and each source link. No persistent:focusfills.prefers-reduced-motioncollapses the card reveal and chevron rotation transitions.