Skip to content

feat: <shiny-aside> markup for source pills + popovers - #278

Merged
cpsievert merged 15 commits into
mainfrom
feat/sidenote-markup
Aug 10, 2026
Merged

feat: <shiny-aside> markup for source pills + popovers#278
cpsievert merged 15 commits into
mainfrom
feat/sidenote-markup

Conversation

@cpsievert

@cpsievert cpsievert commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Why this matters

Assistant responses increasingly need to cite their sources — a web search result, a fetched page, a methodology note — without derailing the prose. This PR adds a general-purpose way to do exactly that: drop an inline <shiny-aside> tag anywhere in a message's markdown and it renders as a small pill at the end of the paragraph (or list item) it belongs to. Hover, click, or keyboard-focus the pill and a popover shows the note's body.

The key design decision is that this is a plain markup convention, not an API. Any content that reaches the chat as markdown can carry these tags — hand-authored, templated, or (the intended path) emitted by an LLM you've prompted to cite its sources. There is no new Python/R function to call and nothing source-specific baked in: Chat.append_message() / chat_append() already stream markdown, and asides ride along inside it.

This is the rendering primitive that the upcoming web search / web fetch citation UI is built on top of (see "Foundation for web citations" below).

What it looks like

Asides render at the end of the block they belong to, regardless of where in the sentence the tag was authored — the markup pass pulls each tag out of its inline position and appends the resulting pill(s) to the tail of the paragraph or list item.

A labeled aside renders as an identity chip — a favicon (derived from url) plus the source name:

Hub motors are cheaper, and ideal for flatter terrain. [🌐 eBicycles]
                                                        └─ end of block ─┘
                                     hover/click/focus opens the popover

When several distinct sources are cited in the same block, their pills collapse into one — the first source becomes the face, the rest become a +N overflow, and the popover turns into a carousel with prev/next controls:

Hub motors are cheaper, and ideal for flatter terrain. [🌐 eBicycles +1]

                                         ┌─────────────────────────────┐
                                         │  ‹  ›              1 / 2     │
                                         │  🌐 eBicycles               │
                                         │  Hub Motor vs. Mid-Drive…   │
                                         └─────────────────────────────┘

An aside with no label falls back to a plain numbered marker ([1], [2], … numbered across the whole message), so anonymous notes still work:

Battery quality matters more than raw power. [1]

A single block can carry more than one pill: all labeled asides merge into one source-chip, while each label-less aside becomes its own numbered marker — so the tail of a paragraph might show a chip and a couple of numbered pills together.

The convention

<shiny-aside label="a source name" url="https://…">
markdown shown in the popover
</shiny-aside>
  • The tag can be authored anywhere in the sentence; it always renders at the end of its block (paragraph or list item).
  • label (optional) — source name shown on the pill/chip. Asides sharing a label within a block are grouped under one chip and paged through (prev/next) rather than deduped — every entry is kept. Without a label, the pill is a numbered marker.
  • url (optional) — makes the source heading in the popover a link and supplies a derived favicon. The URL is sanitized.
  • icon (optional) — overrides the favicon; also URL-sanitized.
  • grounded-span (optional) — identifies answer text that is related to the aside. The value must exactly match preceding text in the same paragraph or list item. When the popover opens, shinychat highlights the most recent match. An unmatched value has no effect.
  • body = the tag's own markdown children. Inline for a one-liner, or — separated by blank lines — a rich block body (paragraphs, lists, code).

Examples

Labeled, one-line body:

await chat.append_message(
    "Hub motors are cheaper"
    '<shiny-aside label="eBicycles" '
    'url="https://ebicycles.example/hub-vs-mid-drive" '
    'grounded-span="Hub motors are cheaper">'
    "[Hub Motor vs. Mid-Drive Motor Differences Explained](https://ebicycles.example/hub-vs-mid-drive)"
    "</shiny-aside>"
    ", and ideal for flatter terrain."
)

Two sources in one sentence collapse into a single pill:

"...cheaper"
'<shiny-aside label="eBicycles" url="https://ebicycles.example">…</shiny-aside>'
'<shiny-aside label="WIRED"     url="https://wired.example">…</shiny-aside>'
", and ideal for flatter terrain."

Label-less aside with a rich block body (blank line ⇒ block body), falls back to a numbered pill:

"Battery quality matters more than raw power"
"<shiny-aside>\n\n"
"**Methodology**\n\n"
"- 40 commuter e-bike models\n"
"- released in 2024\n\n"
"</shiny-aside>"

The same markup works in R via chat_append() — see the Asides section in the ?chat_append / append_message() docs for the full reference.

Try it locally

These example apps aren't part of this PR's diff (kept local, not committed) but are the easiest way to see every scenario at once.

sandbox/demo_asides.py — exhaustive manual scenarios (Python, no API key needed)
# Exhaustive manual test of the public <shiny-aside> markup convention
# (no chatlas / API key needed — this branch does not depend on it).
# Run: uv run shiny run sandbox/demo_asides.py --port 8054
#
# Every scenario renders directly in the startup greeting — nothing to click.
# Scroll the one assistant message to see: same-label paging, overflow + popover
# carousel, label-less sequential numbering (message-scoped, not per-group —
# see the dedicated section below), icon-without-url, missing icon/url,
# special characters, tight/loose/nested lists, multi-paragraph grouping,
# and a rich-markdown popover body.
#
# Typing a scenario name still re-sends it through append_message_stream,
# streamed in small chunks — closer to how a real LLM emits tokens, and
# useful for exercising the streaming-specific code path (the greeting
# itself is rendered statically, not streamed).

import asyncio

from shiny.express import ui

from shinychat.express import Chat

ui.page_opts(title="shinychat — Aside Markup Demo", fillable=True)

SCENARIOS: dict[str, str] = {
    "Show a single cited source": '''Tidy evaluation lets you write functions that work like dplyr data-masking semantics<shiny-aside label="Advanced R" url="https://adv-r.hadley.nz/evaluation.html">[Metaprogramming: Evaluation](https://adv-r.hadley.nz/evaluation.html), the tidy evaluation chapter of Advanced R.</shiny-aside>.''',
    "Cite the same source twice": '''The de facto style guide for R is the tidyverse style guide<shiny-aside label="tidyverse.org" url="https://style.tidyverse.org">[The tidyverse style guide](https://style.tidyverse.org)</shiny-aside>, and most packages on CRAN now follow it<shiny-aside label="tidyverse.org" url="https://style.tidyverse.org">Second citation for the same source — should page as a second entry under the same pill, not add an overflow badge.</shiny-aside>. Expect one pill above with no overflow count, but a 2-entry prev/next carousel in the popover.''',
    "Cite three different sources": '''Three different benchmarks all reach the same conclusion<shiny-aside label="R-bloggers" url="https://www.r-bloggers.com">[R-bloggers benchmark writeup](https://www.r-bloggers.com)</shiny-aside>: vectorized code consistently outperforms explicit loops<shiny-aside label="Advanced R" url="https://adv-r.hadley.nz">[Advanced R, performance chapter](https://adv-r.hadley.nz)</shiny-aside>, often by an order of magnitude or more<shiny-aside label="CRAN Task View" url="https://cran.r-project.org/web/views/HighPerformanceComputing.html">[CRAN Task View: High-Performance Computing](https://cran.r-project.org/web/views/HighPerformanceComputing.html)</shiny-aside>. Expect one pill labeled "R-bloggers" with a "+2" overflow badge, and a 3-way carousel in the popover.''',
    "Label-less numbering, case A — one paragraph, one anonymous claim": '''Internal testing suggests this approach scales to about 40 million rows before memory becomes a bottleneck<shiny-aside>Benchmarked on a 16-core, 64GB machine using data.table with fread().</shiny-aside>. Expect a pill reading "1" (no label, no favicon; accessible name "Aside 1") — this is a running index scoped to the whole message, not a count of this group's entries.''',
    "Label-less numbering, case B — a separate paragraph, also one anonymous claim": '''A completely unrelated claim, in its own paragraph, also with a single anonymous source<shiny-aside>A different benchmark, on different hardware, cited independently of case A above.</shiny-aside>. Expect this pill to read "2" — continuing the running count from case A above, not resetting to "1". Anonymous asides are numbered sequentially across the entire message, regardless of which paragraph or list item they land in.''',
    "Label-less numbering, case C — one paragraph, three anonymous claims": '''This recommendation is backed by three separate signals: internal testing<shiny-aside>Tested on 500 real user sessions over two weeks.</shiny-aside>, user interviews<shiny-aside>12 semi-structured interviews with power users.</shiny-aside>, and support ticket analysis<shiny-aside>Reviewed 200 tickets tagged performance from the last quarter.</shiny-aside>. Expect three separate pills reading "3", "4", and "5" — anonymous asides are never bundled together, even within the same paragraph; each gets its own pill and its own consecutive number, continuing from case B above.''',
    "Label-less numbering, case D — mixing a labeled source with an anonymous claim": '''A cited claim<shiny-aside label="Public Source" url="https://example.com/public">[A public, labeled source](https://example.com/public).</shiny-aside> alongside an uncited one in the same sentence<shiny-aside>An anonymous claim grouped into the same paragraph as the labeled citation above.</shiny-aside>. Expect TWO separate pills: one labeled "Public Source" with no overflow badge (anonymous asides never count toward a labeled pill's overflow), and one numbered pill reading "6" — continuing the running count from case C above.''',
    "Use a custom icon with no link": '''Model responses are graded against a private held-out benchmark<shiny-aside label="Internal Eval" icon="https://icons.duckduckgo.com/ip3/anthropic.com.ico">Internal evaluation suite — not publicly documented, so there is no link, only an explicit icon override.</shiny-aside>. Expect the pill to show the given icon even though no url is set.''',
    "Use a label with no link or icon": '''This is common knowledge among R users<shiny-aside label="Common knowledge">No source needed — this is a widely known fact in the R community.</shiny-aside>. Expect a labeled pill with no favicon image at all.''',
    "Use special characters in a citation": '''Pricing details are on the vendor site<shiny-aside label="Docs &amp; Pricing" url="https://example.com/pricing?plan=pro&amp;ref=chat&amp;utm_source=app">[Pricing page](https://example.com/pricing?plan=pro&amp;ref=chat&amp;utm_source=app) — the label and URL both contain ampersands, to check attribute escaping and link generation.</shiny-aside>. Expect the label to read "Docs & Pricing" and the popover link to point at a URL with literal & characters.''',
    "Cite sources in a tight list": '''Recommended reading, in order of depth:\n\n- Start with the official docs<shiny-aside label="Official Docs" url="https://docs.example.com">[Official documentation](https://docs.example.com)</shiny-aside>\n- Then this in-depth guide<shiny-aside label="Deep Dive Guide" url="https://guide.example.com">[An in-depth guide](https://guide.example.com)</shiny-aside>\n- Finally, the source code itself<shiny-aside label="Source" url="https://github.com/example/repo">[Source repository](https://github.com/example/repo)</shiny-aside>\n\nExpect each list item to carry its own single-source pill.''',
    "Cite sources in a loose list": '''Three separate, independently-sourced points:\n\n- Point one, well substantiated<shiny-aside label="Source A" url="https://a.example.com">[Source A](https://a.example.com)</shiny-aside>\n\n- Point two, also cited<shiny-aside label="Source B" url="https://b.example.com">[Source B](https://b.example.com)</shiny-aside>\n\n- Point three, no citation needed here\n\nBlank lines between items force a loose list (each item wrapped in its own paragraph) — expect the same per-item pills as the tight-list case.''',
    "Cite sources in a nested list": '''- Outer point, cited directly<shiny-aside label="Outer Source" url="https://outer.example.com">[Outer source](https://outer.example.com)</shiny-aside>\n  - Nested sub-point with its own citation<shiny-aside label="Inner Source" url="https://inner.example.com">[Inner source](https://inner.example.com) — must attach to the nested item, not the outer one.</shiny-aside>\n\nExpect two separate pills: one on the outer bullet, one on the nested bullet — neither should steal the other aside.''',
    "Cite sources in separate paragraphs": '''First, consider the frontend<shiny-aside label="Frontend Guide" url="https://frontend.example.com">[Frontend guide](https://frontend.example.com)</shiny-aside>.\n\nNow consider the backend<shiny-aside label="Backend Guide" url="https://backend.example.com">[Backend guide](https://backend.example.com)</shiny-aside>.\n\nExpect two separate pills, one per paragraph.''',
    "Show a rich-text popover body": '''Full details are in the changelog<shiny-aside label="Changelog" url="https://changelog.example.com">Released **v2.4.0** with a new `parallel=` argument and a [full changelog here](https://changelog.example.com). See also *migration notes* below.</shiny-aside>. Expect the popover body to render bold, italic, inline code, and a link — not raw markdown syntax.''',
}

GREETING = "## Aside markup scenarios\n\n" + "\n\n".join(
    f"**{name}**\n\n{content}" for name, content in SCENARIOS.items()
)

chat = Chat(id="chat")
chat.ui(
    greeting=GREETING,
    placeholder="Type a scenario name above to re-stream it in chunks…",
)

async def stream_chunks(text: str, chunk_size: int = 10):
    # Small chunk_size + a visible delay means a <shiny-aside> tag almost
    # always lands split across multiple chunks, and slow enough to watch the
    # incomplete opening tag get hidden until the tag (and its aside)
    # fully arrives.
    for i in range(0, len(text), chunk_size):
        yield text[i : i + chunk_size]
        await asyncio.sleep(0.08)

@chat.on_user_submit
async def _(user_input: str):
    content = SCENARIOS.get(user_input.strip())
    if content is None:
        options = "\n".join(f"- {name}" for name in SCENARIOS)
        await chat.append_message(
            f"Not a recognized scenario. Try one of:\n\n{options}"
        )
        return
    await chat.append_message_stream(stream_chunks(content))

Feature checklist

  • ✅ Inline <shiny-aside> tag anywhere in a block → trailing pill at end-of-block
  • ✅ Labeled → favicon + name chip; label-less → message-scoped numbered marker
  • ✅ Multiple sources per block collapse into one pill with a +N overflow and a prev/next carousel popover; anonymous notes stay as separate numbered pills
  • ✅ Asides sharing a label within a block page through every entry (prev/next) instead of deduping; the +N overflow badge is shown only for mixed-label groups, since paging already conveys the same-label count
  • ✅ Popover body is real markdown — inline or block-level (lists, code, paragraphs)
  • grounded-span highlights the most recent exact preceding match in the same block while its aside is open
  • ✅ Opens on hover / click (pin) / keyboard focus; Escape and click-outside dismiss; rendered through a floating-ui portal so it escapes the message list's scroll container (flips/shifts into view instead of clipping)
  • ✅ Safe source URLs become external links in the popover heading
  • ✅ Long labels and bodies wrap, and the popover scrolls within the available viewport at enlarged text sizes
  • ✅ Portaled popovers preserve the nearest scoped Bootstrap theme
  • ✅ Carousel changes announce the source position and title without repeating the full body
  • Streaming-aware: a pill in the still-open trailing block stays hidden until its block settles (a later block starts, or the stream ends), so it doesn't flash mid-sentence or jitter as text streams past; an incomplete <shiny-aside… opening tag is withheld rather than shown as raw markup
  • ✅ Works under both markdown and content_type="html" messages
  • url and icon are URL-sanitized (defense-in-depth in the HAST pass + React render)
  • ✅ Set SHINYCHAT_ASIDE_FAVICON=false to disable third-party derived-favicon requests across a deployment; explicit icon URLs still work
  • ✅ Available in both Python and R, with rebuilt web assets committed for each package

Consistency with suggestion cards

The aside streaming machinery is deliberately modeled on the existing suggestion-card code, so this reuses a proven pattern rather than inventing new streaming logic. Three concrete parallels:

  1. "Pending trailing block" during streaming. Suggestions use SUGGESTION_PENDING_ATTR + rehypeSuggestionCards + finalizePendingSuggestionLists; asides mirror it with ASIDE_PENDING_ATTR + rehypeMarkTrailingAsides + finalizePendingAsides. Both refuse to commit anything in the still-open last block, and both clear the marker at end-of-stream via the same immutable path-copy strategy (mirroring withStreamingDot) so the cached Stage-1 HAST is never mutated. hastToReact calls the two finalizers together.
  2. Half-streamed opening tag. hideTrailingPartialTag's TRAILING_PARTIAL_ASIDE_RE mirrors suggestions' PARTIAL_SPAN_OPENING_RE (the nested-optional regex matching a tag that hasn't received its > yet). Both stop a partially-arrived tag from flashing as raw text mid-stream.
  3. Inline markup convention → rehype restructuring → rendered form. Both are conventions the LLM emits inline that a rehype pass rewrites into something richer.

Key distinction: suggestions promote in place (the list stays where it sits, just restructured), whereas asides relocate to end-of-block. The shared DNA is the streaming/finalization plumbing, not the placement semantics.

Foundation for web search / web fetch citation UI

This is deliberately the substrate for the citations work, not the whole thing. The public grounded-span attribute restores claim-level context after a pill moves to the block tail. Opening or paging to an aside highlights the most recent exact preceding match in the same paragraph or list item.

Follow-up #280 maps structured ContentCitation.grounded_span metadata onto this public attribute. It also adds citation-only behavior, including the message-wide Sources summary. The data-citation marker controls that citation layer; it is not required for grounded aside behavior.

Implementation notes (for reviewers)

The heavy lifting is a set of rehype passes wired into both the markdown and HTML processors (js/src/markdown/processors.ts):

  • rewriteAsideTemplate — round-trips <shiny-aside> through a <template> disguise across rehype-raw so parse5 keeps block-level children instead of applying the <p>-can't-contain-blocks auto-close rule.
  • rehypeGroundedAsides — connects grounded-span to the most recent exact preceding text in the same paragraph or tight list item, including matches that cross inline formatting.
  • rehypeGroupAsides — collects asides per paragraph/tight-list-item, splices each out of its inline position, and appends the resulting group(s) to the end of the block; labeled asides collapse into one group, while anonymous ones each become their own group stamped with a message-scoped index.
  • markTrailingAsides — marks groups in the open trailing block as pending during streaming; finalizePendingAsides clears them on the settled render via an immutable path-copy (mirrors the suggestion-card pattern).
  • AsideGroup.tsx — adapts grouped markup into normalized entries, then renders the pill + floating-ui popover/carousel.

Tests: JS unit tests for each plugin + the component, plus a Python Playwright app/e2e at pkg-py/tests/playwright/chat/web_asides/.

Introduces a reusable sidenote pill + popover component: any assistant
message can carry inline <shiny-sidenote label="..." url="..." icon="...">
tags anywhere in its markdown, with the popover body as the tag's own
markdown children (supporting both a simple inline body and, via a
blank-line-separated block body, richer content like lists and
paragraphs). Entries are grouped per end-of-block into a labeled chip
(or count-fallback pill) that expands into a hover/click/focus popover
with prev/next navigation across grouped entries. This is a
general-purpose building block, not tied to any particular content
source (e.g. chatlas web search/fetch citations, a follow-up PR, will
build on top of it).

Anonymous sidenotes are numbered sequentially across the whole message,
and that message-scoped index is shown on the pill. The popover opens
after a short grace period and is rendered through a @floating-ui/react
portal so it escapes the message list's scrolling container, flipping
and shifting into view rather than clipping.

Accessibility and safety details:
- Multi-source labeled pills include the overflow count ("+N more") in
  their accessible name.
- The `icon` attribute is URL-sanitized the same way `url` is.
- rehypeGroupSidenotes runs in both the markdown and HTML content
  pipelines, so sidenotes also work under content_type="html".
- A literal `"` inside label/url/body must be HTML-entity-escaped, since
  these are ordinary HTML attributes parsed by rehype-raw/parse5.

The component was originally built and named <shiny-footnote>, then
renamed to <shiny-sidenote> across the JS component, markdown pipeline,
Python/R packages, and docs/tests before landing.

Includes JS/CSS unit tests, a Python Playwright app + test, R docs, and
rebuilt web assets for the Python and R packages.
…closing tags

While a response streams, a sidenote pill no longer flashes mid-sentence
and jitters as text streams past it. It now appears only once its
surrounding block has settled — either a later block has started or the
stream has ended.

Mirrors the existing streaming suggestion-card pattern: a new
rehypeMarkTrailingSidenotes marks groups in the still-open trailing block
as data-pending (SidenoteGroup renders nothing while pending), and
finalizePendingSidenotes clears the markers on the non-streaming render
path in hastToReact via an immutable path-copy that never mutates the
cached HAST.

Also fixes a bug where a self-closing <shiny-sidenote/> was rewritten to
a lone <template …/>, which — like any non-void element — ignores the
slash and swallows the text after it into the popover body. Normalizes
<shiny-sidenote …/> to an open/close pair before the template rewrite,
skipping over quoted attribute values so a slash inside url="https://…"
isn't mistaken for the self-close. Also corrects the broken-icon test to
assert the icon <img> unmounts (matching the intentional switch away from
display:none hiding), and documents in the Python/R sidenote help that the
favicon is fetched at render time from DuckDuckGo and that an explicit
`icon` bypasses that third-party request.
Previously, grouping same-label sidenotes within a block kept only the
first entry and discarded the rest. Now every entry is kept and the group
pages through them (prev/next), matching the existing multi-source
popover behavior. The overflow "+N more" badge is now hidden when all
grouped entries share a single label, since paging already conveys that
there's more than one entry — the badge is only shown for mixed-label
groups where the count isn't otherwise visible.

Updates the Python/R chat_append docs to describe same-label paging and
the now-conditional overflow badge, corrects CHANGELOG/NEWS entries that
still described the old dedup behavior, adds a test covering entry-count
overflow in a mixed-label group, and rebuilds the JS assets for both
packages.
"Aside" better describes the convention: a small pill/popover attached
to a paragraph or list item, not limited to citation-style notes.
Replace hand-rolled hover/pin/blur/keydown handling with useHover,
useFocus, useClick, useDismiss, useRole, and FloatingFocusManager so
open/close/pin state, outside-click dismissal, and focus trapping are
handled by Floating UI instead of bespoke DOM containment checks.
@cpsievert
cpsievert force-pushed the feat/sidenote-markup branch from 7b961a2 to aa2f1a1 Compare July 30, 2026 14:24
@cpsievert
cpsievert marked this pull request as ready for review July 30, 2026 14:24
@cpsievert
cpsievert requested a review from gadenbuie July 30, 2026 14:25
@cpsievert cpsievert changed the title feat: <shiny-sidenote> markup for source pills + popovers feat: <shiny-aside> markup for source pills + popovers Jul 30, 2026

@gadenbuie gadenbuie left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a useful, well-tested annotation primitive, and the streaming/floating interaction work is thoughtful. I have a few comments I think we should resolve before merging — feel free to resolve at your discretion and merge.

The HTML content path currently loses unwrapped asides and corrupts rich aside bodies, which is a blocking correctness issue. The popover also needs bounded scrolling and text-resize/long-content handling before rich model-generated content is safe to ship.

At the design level, I would like us to clarify whether paragraph-tail aggregation intentionally gives up claim-level citation mapping, and whether <shiny-aside> is meant to be a compatibility-bound public wire format. My preference is to keep markup as an authoring adapter into a shared normalized aside/citation model, rather than making serialized markup canonical for future structured web results.

The remaining comments cover source actionability and privacy, documentation accuracy, accessibility, scoped theming, and noisy interaction tests.

* markdown parsers wrapping inline HTML in <p> tags, which doesn't apply to a
* parse5-parsed HTML fragment.
*/
export const htmlProcessor = unified()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Required: The HTML path does not apply the pre-parse template disguise used by the Markdown path, while aside grouping happens only after parse5 has interpreted the markup. This causes two concrete failures: a root-level inline <shiny-aside> is never grouped and renders as null, and block content inside an aside can cause parse5 to close the element early, leaving an empty pill and leaking the body into the message. Please make HTML input preserve and group asides before browser-style parsing can alter them, and add coverage for both unwrapped inline asides and rich/block aside bodies.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in aec8b838.

Raw HTML now protects <shiny-aside> before parse5 runs and restores it before HAST processing. Regression tests cover root-level asides and rich block content.


function transform(tree: Root): void {
let asideIndex = 0
visit(tree, "element", (node: Element) => {

@gadenbuie gadenbuie Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: How committed are we to moving every aside in a paragraph to the paragraph tail? For citations, this loses the authored claim-to-source position and can make several independent claims look like one citation cluster. I recommend retaining a group at each authored location, grouping only adjacent/co-located asides, and deduplicating identical sources within that local group (prefer normalized URL identity, with exact attributes/body as a fallback).

@cpsievert cpsievert Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have kept paragraph-tail aggregation for the web search and fetch use case. Multiple citations within one sentence are common, and a pill at every authored position makes the response harder to read.

#278 now restores claim-level context through the public grounded-span attribute. Any <shiny-aside> can identify exact preceding text in the same paragraph or list item. Opening or paging that aside highlights the most recent match. Missing or unmatched values have no effect.

#280 maps upstream ContentCitation.grounded_span values onto that public attribute. Citation occurrences remain distinct locally, including repeated URLs. Only the message-wide Sources summary deduplicates URLs.

Implemented on #278 in 9bf5da3 and integrated into #280 in 12fae80.

Comment thread js/src/chat/AsideGroup.tsx Outdated
return typeof v === "number" ? v : undefined
}

export function parseAsideEntries(node?: Element): AsideEntry[] {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Can we establish a shared normalized AsideEntry boundary here? The rehype element should be an authoring adapter into that model, while future structured citation/web-result data should feed the same renderer directly. That avoids making serialized <shiny-aside> markup the canonical citation representation or requiring structured data to be escaped, serialized, and reparsed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 1fb8d8d4.

<shiny-aside> now acts as an adapter from HAST into normalized AsideEntry objects. The exported AsideGroupView renders normalized entries directly, and its regression test does not use a HAST node. Future structured citation data can use the same renderer without serializing <shiny-aside> markup.

@cpsievert cpsievert Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The normalized boundary now separates generic aside state from citation metadata. AsideEntry.groundingId is top-level state that any grounded aside can use. Citation metadata contains only citation-specific fields, such as title and cited_quote, and the Sources collector consumes normalized citation entries.

The current Python and R transports map upstream ContentCitation.grounded_span values to the public grounded-span attribute. Markup remains an adapter at the client boundary. A future structured transport can populate normalized AsideGroupView entries directly.

Implemented on #278 in 9bf5da3 and integrated into #280 in 12fae80.

}))
}

export function faviconUrl(url: string): string | undefined {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Required: Supplying a URL currently causes the client to contact DuckDuckGo for a favicon. That leaks source domains and user activity to a third party and may violate deployment CSP/privacy requirements. Please provide an app/chat-level way to disable or replace the resolver, with a no-icon fallback; this should not require each generated aside to opt out individually.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 3a631e92 as a deployment-wide policy rather than a new per-chat argument.

SHINYCHAT_ASIDE_FAVICON=false prevents derived DuckDuckGo requests from every chat in the process, which gives operators one privacy and CSP policy. Python and R validate the setting. Explicit icon= values still render, so apps can use controlled assets.

This setting does not provide different favicon policies for multiple chats in one process. If that use case is required, it needs a separate public API.

Comment thread js/src/chat/AsideGroup.tsx
</span>
</div>
)}
{current.label && (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Required: url fetches an icon but does not provide a clickable destination: the source heading remains a plain div. A supplied source URL should produce an actual link inside the popover, using the project’s existing external-link behavior and accessible naming. Otherwise the API exposes the source location while preventing users from visiting it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in aec8b838.

Safe source URLs now render as external links in the popover heading and use the existing link handling. Unsafe URLs remain non-clickable.

Comment thread js/src/chat/chat-tools.scss Outdated
// (.shiny-chat-lightbox at 1080, fullscreen tool card at 1050) — now
// that this lives at the body level rather than inline with the message.
z-index: 1040;
min-width: 14em;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Required: Model-generated content can make this popover unusable: a long body produced a roughly 1,990px-tall fixed popover with no internal scrolling; at 200% text size it overflowed a 320px viewport; and an unbroken label expanded the pill to roughly 1,292px. Please add a compact viewport-aware max height with internal scrolling, cap width to the available viewport, and apply safe wrapping/overflow rules to labels and URLs. Add regression coverage at 200% text size and with long unbroken content.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in aec8b838.

The popover now has viewport-aware size limits, internal scrolling, and long-content wrapping. Playwright covers a 320px viewport at 200% text size.

text-align: start;
white-space: normal;

.shiny-aside-popover__nav {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Required: Please raise the carousel control opacity to at least 0.65 and verify contrast in both light and dark themes. The current 0.6 measures only about 4.37:1 in the tested light theme, leaving little margin. The measured previous/next buttons are also only about 26 x 22px; increase the interactive hit area to at least 24px in both dimensions, preferably 32px, while keeping the glyph visually compact.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in aec8b838.

Carousel buttons now have a minimum 24px by 24px hit area and opacity of 0.65. Playwright verifies both hit-area dimensions. The opacity threshold is set directly on the navigation container in CSS.

This change does not add a theme-specific contrast assertion because scoped theme propagation remains a separate review thread.

Comment thread js/tests/chat/AsideGroup.test.tsx Outdated
)
})

it("closes on mouse-leave when not pinned", () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Required: These interaction tests emit repeated React act(...) warnings even though they pass. That means state updates are escaping the test synchronization boundary and can hide real failures in CI noise. Please use userEvent, await observable updates with findBy/waitFor, and correctly advance/flush any timers so the new suite runs warning-free.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in aec8b838.

The delayed hover tests now use awaited user interactions and real timers. The focused AsideGroup suite passes without React act(...) warnings.

Comment thread pkg-py/src/shinychat/_chat.py Outdated
`url`, unless `icon` overrides it); without one, it falls back to a
plain numbered/count marker. The body is ordinary markdown: inline for a
one-liner, or — by separating it with blank lines — a rich block body
(paragraphs, lists, code) shown in the popover. Multiple asides in

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Required: This documentation says all asides in a message collapse into a single element, but the implementation only groups labeled asides; anonymous asides remain separate numbered pills. Please document that distinction consistently in the Python and R APIs, generated Rd, and release notes (or change the implementation if one-group behavior is the intended contract).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 84b1dd21.

The Python and R API documentation, generated Rd, and release notes now distinguish labeled aggregation from anonymous notes. Labeled asides in one block share a carousel pill. Each unlabeled aside remains a separate numbered pill.

# Conflicts:
#	js/dist/shinychat.css
#	js/dist/shinychat.css.map
#	js/dist/shinychat.js
#	js/dist/shinychat.js.map
#	js/src/chat/ChatApp.tsx
#	js/src/chat/chat-entry.ts
#	js/src/chat/chat-tools.scss
#	js/tests/markdown/MarkdownContent.test.tsx
#	pkg-py/CHANGELOG.md
#	pkg-py/src/shinychat/www/GIT_VERSION
#	pkg-py/src/shinychat/www/shinychat.css
#	pkg-py/src/shinychat/www/shinychat.css.map
#	pkg-py/src/shinychat/www/shinychat.js
#	pkg-py/src/shinychat/www/shinychat.js.map
#	pkg-r/NEWS.md
#	pkg-r/inst/lib/shiny/GIT_VERSION
#	pkg-r/inst/lib/shiny/shinychat.css
#	pkg-r/inst/lib/shiny/shinychat.css.map
#	pkg-r/inst/lib/shiny/shinychat.js
#	pkg-r/inst/lib/shiny/shinychat.js.map
#	pkg-r/tests/testthat/test-chat.R
@cpsievert
cpsievert merged commit 3c47064 into main Aug 10, 2026
18 checks passed
@cpsievert
cpsievert deleted the feat/sidenote-markup branch August 10, 2026 14:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants