feat: auto-promote suggestion lists to clickable card grid - #219
Merged
Conversation
Promote top-level <ul>/<ol> lists where every <li> contains exactly one suggestion element into a card grid. The plugin annotates the existing suggestion markup with stable class hooks (shiny-chat-suggestion-list, shiny-chat-suggestion-list-item, -item-title, -item-body), preserving the existing click-handler contract. Title attributes become card headings; data-suggestion is locked to body text so submissions are not contaminated by titles. Ordered lists prepend the 1-based index. Registered after rehypeAccessibleSuggestions so cards inherit role/tabindex/aria-label. Refs #218
Auto-fit grid with single-card width clamp via :has(). Accent outline for the last-clicked card sits on the inside border so it does not clash with :focus-visible. Scoped under shiny-chat-container.
…cards Strict roving tabindex within each card grid (one Tab stop per grid; Arrow/Home/End move focus between cards). Lazy setup on first focusin avoids needing a mutation observer for streaming content. Activating a card marks it with data-last-clicked and clears the attribute from siblings.
Note in the Python "Input suggestions" callouts and the R get-started vignette that a markdown list of suggestion elements is rendered as a card grid, with the optional title attribute becoming the card heading.
Set the list font-size to 0.875rem (matching .shinychat-thinking) so titles and bodies both scale down together. Expose --shiny-chat-suggestion-card-min-width (default 175px) so apps can tune card density without overriding the grid template.
When a trailing top-level list looks like a suggestion grid in progress (every <li> is either a single suggestion element or still empty, and at least one suggestion is present), mark the wrapper with data-pending instead of promoting it to cards. CSS hides the partial <li> children and renders a subtle "Generating suggestions…" placeholder with a gentle pulse, so users do not see chips flash in and out as the model streams the list. On the next pass, once every <li> contains its suggestion, the normal qualifying-list branch takes over and the real cards swap in.
A trailing top-level <ul>/<ol> is treated as pending whenever any <li> has a direct suggestion child — even if the list happens to fully qualify at that moment. Promotion to cards now happens only when a new top-level block lands after the list, or when streaming ends. The rehype plugin can't tell "trailing because still streaming" from "trailing because end of message", so it never promotes a trailing list. hastToReact runs finalizePendingSuggestionLists on the cached HAST when streaming flips off: pending lists that qualify get promoted, non-qualifying lists drop the pending markers and render natively. This removes mid-stream flicker between pending and rendered cards (seen when the model briefly had N complete suggestions before adding the N+1th).
Read --shiny-chat-suggestion-card-min-width through a private internal property so the public variable can be set on :root (or any ancestor) and cascade into .shiny-chat-suggestion-list.
Stop streamingDot from recursing into a <ul>/<ol> with data-pending so the dot lands as a direct child of the wrapper rather than inside a hidden <li>. Pending row is now flex with the placeholder text and the dot laid out inline; the dot uses currentColor and has its inline left margin zeroed. The wrapper opacity pulse runs on the same 1.75s cubic-bezier timing and 250 ms delay as the dot so they fade in sync.
Each card fades from opacity 0 and scales 0.9 → 1 over 0.3s with a slight overshoot, staggered by --shiny-chat-suggestion-card-reveal-stagger (default 100ms) so the grid ripples in left-to-right. Suppressed when prefers-reduced-motion is set.
Add viewBox to the streaming-dot SVG so CSS width/height resize the artwork instead of clipping the unscaled <circle>. Pending placeholder sizes the dot at 0.55em to sit more comfortably against the smaller placeholder text.
Move hasSuggestionClass, getTextContent, and isSuggestionElement into a new suggestionHelpers module that both rehype plugins import. Add a SUGGESTION_PENDING_ATTR constant so streamingDot and rehypeSuggestionCards agree on the data-pending marker without literal string duplication.
- Tighten isPendingSuggestionList: every direct <li> must be empty or have a single suggestion child. Lists where a <li> mixes a suggestion with adjacent content, or contains plain text without a suggestion, exit pending immediately. - finalizePendingSuggestionLists now returns a new Root with the pending list's spine path-copied; the cached HAST passed in is never mutated. Restores the "cache is immutable" invariant after the previous in-place fix. - Add role="list" on the wrapper and role="listitem" on each <li> so list semantics survive display:contents. - Overwrite aria-label in promoteListToCards to include the visible title (and 1-based index for <ol>) so screen readers hear the same content sighted users see. - Set --_card-index on each card via inline style so the staggered reveal animation no longer caps at 12 items. - Tests: streaming → done → streaming round-trip on the same content; aria-label assertions for titled <ul>/<ol>; role assertions; new disqualification cases for the tightened pending rule.
…s-out Replace the `next = -1` sentinel in onSuggestionKeydown with a small nextCardIndex helper that returns number | null and a didNavigate branch. Clear data-roved on the grid when focus moves outside it so a re-rendered card set re-runs the roving setup on the next focusin.
… press feedback - Reveal: cubic-bezier(0.22, 1, 0.36, 1) (ease-out-quint) with translateY entry instead of scale + overshoot - Pending pulse: ease-in-out (overshoot curve did nothing useful on opacity) - Add :active translateY(1px) for tactile press feedback - [data-last-clicked]: drop 2px inset outline; tinted surface + brand border instead
Polish pass 1 of N: add directional cues and depth without changing the silhouette. - Hover: 1px lift + soft brand-tinted shadow - Persistent → chevron in bottom-right, faded at rest, opaque and slid on hover/focus — signals the card is a sendable prompt - [data-last-clicked]: chevron becomes ✓ to reinforce the sent state - prefers-reduced-motion: suppress lift and chevron slide
During streaming, the trailing <li> can briefly contain partial markup (e.g. "<span class=\"suggestion\"" before the tag closes), which under the strict pending heuristic disqualified the entire list and caused the cards to flash to a plain bullet list mid-stream. isPendingSuggestionList now ignores the final <li> when evaluating, on the basis that the trailing list is by definition mid-stream when this function runs. Non-last items still have to be empty or hold exactly one suggestion child — no regression on the original tightening. finalizePendingSuggestionLists re-runs the strict isQualifyingList at stream end, so any list that doesn't truly qualify cleanly demotes. Single-li trailing lists are allowed to enter pending so the very first in-progress suggestion doesn't flash as plain bullets.
Polish pass 2 of N: give the cards a quiet surface treatment so they read as offered actions rather than blank panels. - Rest surface: brand-tinted neutral (color-mix 96% body / 4% primary) + inset 1px top-edge highlight for subtle depth - Hover surface: deeper tint (88% body / 12% primary), replacing the warmer --bs-tertiary-bg which clashed with the new tinted rest - [data-last-clicked]: deepen from 92% to 86% so the selected state stays distinct against the now-tinted rest - Title: line-height 1.25 + slight negative letter-spacing - Body: line-height 1.45, color shifted off flat gray to a brand-tinted neutral (avoids "gray on color" against the tinted surface)
…cator
Polish pass 3 of N: a small numeric index in the top-left frames the
cards as an offered set ("1, 2, 3 of N") rather than three parallel
buttons, and gives keyboard users a visual hook for the roving focus.
- ::before reads counter(card-number) seeded from --_card-index so
cards stay numbered regardless of streaming order or list length
- Tabular-nums + 0.6 opacity at rest, full opacity on hover/focus
- [data-last-clicked]: number becomes ✓ at top-left; bottom-right
chevron is suppressed so the selected state has a single, prominent
indicator instead of two
This reverts commit a8a702a.
Refines Polish #3 after testing: - Corner number ::before is now scoped to .shiny-chat-suggestion-list--ordered only; <ul> cards return to a clean title with no top-left badge - promoteListToCards no longer prepends "1." / "2." to ordered titles — the corner badge carries the index; aria-label still announces "#N:" - Restore the bottom-right → → ✓ selected indicator on all cards; for ordered lists the corner number stays visible at full opacity when selected
The top-right corner is always empty (title sits top-left, chevron bottom-right), so anchoring the number there lets <ol> and <ul> cards share identical horizontal padding instead of nudging ordered cards 1rem to the right just to clear a badge.
The selected state was resetting transform to none, so clicking a hovered card snapped the icon back 2px to the left as → swapped to ✓. Match the hover translateX(2px) so the icon stays put through the swap.
The prior loosening (commit 7e008d9) treated the trailing <li> of a streaming list as in-progress regardless of content, so plain single- item bulleted lists like "* Merge conflicts that somehow affect files" briefly rendered as "Generating suggestions…" before settling. Tighten the in-progress check on the last <li>: it qualifies only when empty, when it contains a complete suggestion element, or when its sole significant child is a text node matching a prefix of a "<span..." opening tag (no closing > yet). Plain text and mixed content (suggestion + trailing text) no longer count as in-progress. PARTIAL_SPAN_OPENING_RE is anchored on the whole trimmed text and matches "<", "<s", "<sp", "<spa", "<span", and "<span " followed by partial attributes — anything specific enough to be evidence that a suggestion span is about to materialize.
gadenbuie
marked this pull request as ready for review
May 13, 2026 20:58
cpsievert
reviewed
May 14, 2026
Collaborator
|
Oh, almost forgot. Let's make sure to mention this in the news / changelog. |
Collaborator
|
Now that I'm giving this a test run on some real examples, I noticed sublists don't seem to be supported at the moment. Does it feel like we should also be supporting something like this? Here are a few ideas to get you started:
* **Analyze the data**
* <span class="suggestion">What is the average body mass of the penguins?</span>
* <span class="suggestion">How many species are in this dataset?</span>
* **Create visualizations**
* <span class="suggestion">Show me a bar chart of penguin counts by species.</span>
* <span class="suggestion">Plot a scatter plot of bill length vs. bill depth.</span>
* **Filter and sort records**
* <span class="suggestion">Show me all penguins from the Biscoe island.</span>
* <span class="suggestion">Sort the penguins by flipper length, largest to smallest.</span> |
Collaborator
Author
It's certainly an interesting example, but my take at the moment is that it's complicated enough that it we might not want to. It's also relatively easy to prompt into a different layout, e.g. or I'll open an issue to come back to this |
The rehypeAccessibleSuggestions plugin sets a generic aria-label during the processor pipeline, before finalizePendingSuggestionLists promotes pending lists. The guard in promoteListToCards then skipped setting the title-aware label because one already existed. Remove the guard so promotion always sets the most specific aria-label.
This was referenced May 21, 2026
Closed
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 #218
Screen.Recording.2026-05-13.at.4.29.04.PM.mov
Summary
A markdown list (
<ul>or<ol>) in which every item is a single<span class="suggestion">element is now rendered as a grid of clickable cards instead of inline chips. The existing inline-chip mechanism is untouched.Why a rehype plugin, not a React component. Promoting at the HAST stage keeps the suggestion element as the click target, so
ChatContainer's existingtarget.closest(".suggestion, [data-suggestion]")handler keeps working unchanged. The plugin annotates the existing tree with layout classes, wraps the suggestion body, optionally extractstitleinto a heading, and locksdata-suggestionto the body text — required because injecting a title node would otherwise contaminate the existingel.textContentsubmission fallback.Streaming. A trailing list is marked
data-pendingand renders as "Generating suggestions…" with the streaming dot inline. The qualification rule for the in-progress<li>is evidence-based: it must be empty, contain a complete suggestion element, or hold a text node matching a prefix of a<span ...>opening tag (PARTIAL_SPAN_OPENING_RE). Plain bulleted lists like* Merge conflicts that affect filestherefore never enter the pending state.finalizePendingSuggestionListsruns at stream end, path-copying the pending spine and re-validating against the strict rule — the cached HAST is never mutated.Roving tabindex. Suggestion grids get one Tab stop each; arrow keys / Home / End rove between cards with wrap. Setup is lazy on first focusin (marked with
data-roved) and reset on focus-out so a re-rendered grid is re-initialised on next entry.data-last-clickedsurvives for the lifetime of the message DOM.Public CSS vars.
--shiny-chat-suggestion-card-min-width(default175px) and--shiny-chat-suggestion-card-reveal-stagger(default100ms) let callers tune layout and motion without touching shinychat selectors.Tests. 342 passing (30 files). Unit tests for the plugin (qualifying / non-qualifying shapes, partial-span evidence rule, streaming round-trip on cached HAST, aria-label, roles), plus integration tests covering the click → submit invariant (body text only, never title-prefixed),
data-last-clickedlifecycle, and roving navigation.Verification
Render a qualifying list in any chat message:
```markdown
Here are a few directions we could take this:
```
Confirm:
Live demo — Python (chatlas + Anthropic)
`_dev/agents/suggestions/app.py` drives a real model with a system prompt that teaches it the suggestion-list syntax. Requires an `ANTHROPIC_API_KEY`:
```bash
uv run shiny run _dev/agents/suggestions/app.py
```
Live demo — R (ellmer + Anthropic)
`_dev/agents/suggestions/app.R` is the R equivalent. Requires `ANTHROPIC_API_KEY`:
```bash
Rscript -e "shiny::runApp('_dev/agents/suggestions/app.R')"
```
Streaming debug harness (no API key needed)
`_dev/agents/suggestions/debug.R` injects hand-crafted chunked streams so the pending → cards transition is reproducible at arbitrary speed. Sidebar exposes a stream preset (ul titled / ul body-only / ol titled / ol body-only / plain bulleted negative control), a 1–6 suggestion-count slider, and a chunk-delay slider.
```bash
Rscript -e "shiny::runApp('_dev/agents/suggestions/debug.R')"
```
Useful for verifying the partial-`` evidence rule and reduced-motion behaviour.