Skip to content

feat: auto-promote suggestion lists to clickable card grid - #219

Merged
gadenbuie merged 40 commits into
mainfrom
feat/218-suggestion-lists
May 14, 2026
Merged

feat: auto-promote suggestion lists to clickable card grid#219
gadenbuie merged 40 commits into
mainfrom
feat/218-suggestion-lists

Conversation

@gadenbuie

@gadenbuie gadenbuie commented May 13, 2026

Copy link
Copy Markdown
Collaborator

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 existing target.closest(".suggestion, [data-suggestion]") handler keeps working unchanged. The plugin annotates the existing tree with layout classes, wraps the suggestion body, optionally extracts title into a heading, and locks data-suggestion to the body text — required because injecting a title node would otherwise contaminate the existing el.textContent submission fallback.

Streaming. A trailing list is marked data-pending and 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 files therefore never enter the pending state. finalizePendingSuggestionLists runs 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-clicked survives for the lifetime of the message DOM.

Public CSS vars. --shiny-chat-suggestion-card-min-width (default 175px) and --shiny-chat-suggestion-card-reveal-stagger (default 100ms) 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-clicked lifecycle, and roving navigation.

Verification

Render a qualifying list in any chat message:

```markdown
Here are a few directions we could take this:

  • Show me a worked example with code.
  • What are the common mistakes people make here?
  • Explain the underlying mechanism in more detail.
    ```

Confirm:

  • Tabs into the grid once; arrows rove; Enter/Space submits; activated card stays outlined.
  • A plain bulleted list streamed at end-of-message does not flash "Generating suggestions…".
  • A streaming suggestion list shows the placeholder while incoming, then transitions cleanly to cards.
  • `prefers-reduced-motion: reduce` suppresses hover lift, chevron slide, card reveal, and pending pulse.
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.

gadenbuie added 30 commits May 13, 2026 13:27
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
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.
gadenbuie added 4 commits May 13, 2026 15:57
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
gadenbuie marked this pull request as ready for review May 13, 2026 20:58
@gadenbuie
gadenbuie requested a review from cpsievert May 13, 2026 20:58
Comment thread js/src/markdown/plugins/rehypeSuggestionCards.ts

@cpsievert cpsievert 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.

Nice, thanks!

@cpsievert

Copy link
Copy Markdown
Collaborator

Oh, almost forgot. Let's make sure to mention this in the news / changelog.

@cpsievert

Copy link
Copy Markdown
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>

@gadenbuie

Copy link
Copy Markdown
Collaborator Author

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?

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.

**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>

or

### 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>

I'll open an issue to come back to this

gadenbuie added 2 commits May 14, 2026 14:26
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.
@gadenbuie
gadenbuie merged commit 19df768 into main May 14, 2026
18 checks passed
@gadenbuie
gadenbuie deleted the feat/218-suggestion-lists branch May 14, 2026 18:43
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.

Suggestion lists

2 participants