Skip to content

feat: add dynamic card/value box dashboard tool#266

Draft
gadenbuie wants to merge 39 commits into
mainfrom
feat/cards-tool
Draft

feat: add dynamic card/value box dashboard tool#266
gadenbuie wants to merge 39 commits into
mainfrom
feat/cards-tool

Conversation

@gadenbuie

Copy link
Copy Markdown
Contributor

Summary

Adds a new querychat_card LLM tool that lets the model promote query results and insights into a persistent, developer-placed dashboard area. Cards are opt-in: include "cards" in the tools parameter and place $ui_cards() in your layout.

  • Four display typestable, visualization, markdown, value_box — with a uniform schema ({id, display, title, query, text, theme, icon}) and five actions (add, patch, replace, remove, get)
  • Value box column-mapping: a single-row SQL query drives the box; columns named value, title, text, theme, or icon override the corresponding static card fields
  • Markdown interpolation: when a markdown card has a query, columns become {{var}} mustache placeholders in the text body
  • Shareable cards: $cards_url() encodes cards as gzip + base64 into a URL query param, independent of Shiny bookmarks; author-seeded cards via the cards argument on QueryChat$new()
  • Bookmark support: new bookmark_enable / bookmark_store parameters with smart auto-resolution; enable_bookmarking deprecated with a lifecycle warning
  • Bundled app: querychat_app() gains a page_navbar layout with Data + Insights tabs; Insights tab appears only when cards are enabled

Verification

library(querychat)

chat <- querychat(
  palmerpenguins::penguins,
  tools = c("filter", "query", "visualize", "cards")
)
chat$app()

Ask the assistant to create cards — e.g. "Show me average body mass by species as a value box" or "Summarize the dataset in a markdown card." Cards appear in the Insights tab and persist across the conversation.

For author-seeded cards:

chat <- querychat(
  palmerpenguins::penguins,
  tools = c("filter", "query", "visualize", "cards"),
  cards = list(
    list(display = "value_box", title = "Penguin Count", query = "SELECT COUNT(*) as value FROM data")
  )
)
chat$app()

gadenbuie added 30 commits June 26, 2026 14:05
Add a "cards" tool category that lets the LLM promote query results and
insights into a persistent, developer-placed dashboard area via a new
`querychat_card` tool and `$ui_cards()` method.

- New `tool_card()` (R/querychat_card.R) with add/update/remove actions and
  per-type validation: table (test_query), visualization (ggsql), value box
  (exactly 1x1), and markdown.
- Card store + `manage_card` callback, `render_card()` renderer for all four
  card types, and `mod_ui_cards()` in the Shiny module; cards persist across
  bookmarks.
- `$ui_cards()` method plus `card_placeholder`/`card_layout` configuration on
  `$server()`.
- Conditional system prompt section and `tool-card.md` tool description.
- 11-cards-app example and test-querychat_card.R coverage.
- isolate cards reactiveVal read in manage_card (tool runs outside a
  reactive context)
- pass required id to input_code_editor in card code tabs
- render table/visualization cards with navset_card_underline, a leading
  nav_spacer, and icon-only tab titles
- validate value box icon in the card tool so bad bsicons names return
  to the model instead of crashing the render
Replace the card tool "update" action with two explicit actions:
"replace" (full overwrite, the former "update" behavior) and "patch"
(overlay only the supplied fields onto an existing card). Patch fetches
the existing card via a new manage_card("get") path, merges with
utils::modifyList(), then runs the same validation as replace.
Collapsed the two-axis type/display taxonomy into a single `display`
enum with four values: table, visualization, markdown, value_box. Merged
the `footer` (card) and `subtitle` (value_box) fields into a single
`caption` field. Schema is now uniform: every card is
{id, display, title, value, caption, theme, icon}.

Icon is now validated and honored by all display types: value_box uses
it as showcase, table/visualization/markdown render it in the card
header next to the title. Theme remains value_box-only.

Updated tool arguments, validator, renderer, prompt docs, and tests
(45 card tests, 780 total).
Replace the single flat `layout_columns()` grid with run-based layout:

- Walk the card list and coalesce consecutive cards of the same kind
  (value_box vs content) into runs. Each run renders as its own
  `bslib::layout_column_wrap()` block; blocks are stacked vertically via
  `htmltools::tagList()`, preserving insertion order.
- Value-box runs use `width = "200px"`; content runs (table,
  visualization, markdown) use `width = "400px"` and
  `heights_equal = "row"` so short markdown cards don't stretch to match
  tall tables or charts.
- Extract `coalesce_card_runs()` as a named helper in
  `querychat_module.R` for clarity.

BREAKING CHANGE: Remove `card_layout` parameter from `$server()` and
`mod_server()`. Layout is now fully automatic. The parameter was
previously forwarded to `layout_columns()` (e.g. `list(col_widths =
c(6, 6))`); callers passing `card_layout` must remove it.
Extract each display branch of render_card() into its own happy-path
helper (render_card_value_box/table/visualization/markdown) and reduce
render_card() to a switch() dispatcher.

Centralize error handling: move the per-branch tryCatch up into
render_card(), which now returns a single danger-accented error card
(render_card_error) carrying the condition message for any display type,
including value boxes.
Vendor rlang's standalone-purrr shim and replace base vapply()/Filter()
calls in querychat_module.R with map_chr()/map_lgl()/discard().
Set the chat tool-call title from the action: "Add Card", "Replace
Card", "Update Card" (patch), "Remove Card" — via the result's
extra$display$title, overriding the static "Update Cards" annotation.

Rework tool-card.md and prompt.md to lead with action:"patch" as the
preferred edit and clarify it only needs the fields being changed.
Add an action:"get" to querychat_card so the model can read existing
cards — all cards when "id" is omitted, or a single card by "id". This
also sets up future card-restore, where the model needs to query the
cards that already exist.

Drop cards_summary from the mutation responses. Add/replace/patch/remove
now return {id, status}; get returns the cards' full definitions as a
JSON array (all) or object (one), id-first with unset fields omitted.
Card ids were 16 hex chars (random_hex() default); shorten to 4 chars so
the model can type them easily for patch/replace/remove/get. Add
new_card_id() which regenerates on the rare collision with an existing
card id.
When the "cards" tool is enabled, $app_obj()/querychat_app() now show the
data table and an "Insights" tab (wiring up $ui_cards()) in a tabbed
card. The default app (cards disabled) is unchanged.
The bundled app now has an Insights tab, so querychat_app() defaults to
tools = c("filter", "query", "visualize", "cards"). An explicit tools
argument overrides it. querychat() and QueryChat$new() keep the
c("filter", "query") default.
When the cards tool is enabled, guide the model to offer pinning
noteworthy insights as clickable suggestions when they surface, rather
than only on an explicit request. Capped to one or two per turn to avoid
spamming offers for routine results.
$app_obj() now always uses bslib::page_navbar(). The Data tab holds the
SQL query card and the data table; the Insights tab (cards UI) appears
when the "cards" tool is enabled. The close button moves into a
nav_item().
Overload `enable_bookmarking` on `$server()`/`mod_server()` to accept a
character subset of c("conversation", "cards") in addition to TRUE/FALSE.
This lets cards be bookmarked independently of conversation history, so an
app configured with `enable_bookmarking = "cards"` produces links that open
with the same insights but a fresh conversation.

- Add `normalize_bookmark_categories()` to coerce TRUE/FALSE/NULL/subset
  into a validated character vector of categories.
- Fold the dashboard filter (sql/title), greeting, and inline viz widgets
  into the "conversation" category; cards into the "cards" category.
- Drive the bookmark trigger ourselves for cards-only bookmarking, since
  shinychat only triggers on conversation activity.
- Namespace all bookmark state keys with `session$ns()` so multiple
  QueryChat instances in one app don't clobber each other's state.
Two bugs surfaced when bookmarking cards to the URL:

- Drop the manual `session$ns()` on bookmark state keys. Shiny's module
  scope already namespaces `state$values` per module id, so pre-namespacing
  double-prefixed the keys (e.g. `id-id-querychat_cards`). Flat keys are
  correct and still collision-safe across multiple instances.
- Shiny decodes URL state with `jsonlite::fromJSON(simplifyDataFrame = TRUE)`,
  turning the bookmarked card array into a data.frame and breaking restore
  with "$ operator is invalid for atomic vectors". Add `restore_record_list()`
  to rebuild the list-of-lists shape (dropping absent optional fields), and
  apply it to both cards and viz widgets on restore.
… to tool args

Dedupe the card prompting across its three surfaces so each has one job:
ellmer tool `arguments` carry the per-parameter contract, tool-card.md covers
purpose/when-to-use/displays/failure behavior, and prompt.md covers conversational
judgment only (dropped the mechanical patch/replace/get bullets).

Remove the redundant Parameters/Returns sections from tool-card.md (now covered by
the inline ellmer arg descriptions, matching #250) and reword the arg descriptions
to match.

Also: encourage one value_box per metric for small sets (~3-4); instruct the
value_box SQL to format the value as a human-readable string; keep the prompts
ASCII-only (no em-dashes); and generalize bslib/bsicons references to Bootstrap.
Cards are R-only today; Python registers no card tool yet. This stages the card
tool prompt so it's ready when the Python port lands. Keeps the Parameters/Returns
sections per the Python convention (chatlas extracts param docs from __doc__).
The proactive-offer guidance only described what was worth pinning. Add the
signals that should trigger an offer: ah-ha results, the user showing interest
(follow-up questions, circling back, reactions), and several related findings
accumulating over a few exchanges. Guardrails unchanged (offer as a suggestion,
one or two per turn, never routine lookups).
…gonalize bookmark axes

Rename the $server() argument enable_bookmarking to bookmark_enable, now that
it selects which categories of state to bookmark rather than a plain on/off.
The old name remains as a soft-deprecated alias (lifecycle::deprecate_warn).

Add bookmark_enable to $app(), $app_obj(), and querychat_app() (new, unreleased)
and make the two bookmarking arguments orthogonal:

- bookmark_enable controls whether + what is saved (TRUE/FALSE/categories).
- bookmark_store controls where state is stored (url/server/disable).

Nothing is bookmarked when either axis is off; neither argument mutates the
other. $app_obj() resolves an effective store from both, so e.g.
bookmark_store="url" + bookmark_enable=FALSE now passes "disable" to shinyApp
rather than enabling an empty URL store.

BREAKING CHANGE: $server(enable_bookmarking=) is deprecated in favor of
bookmark_enable.
…e supplied

Addresses review findings on the previous commit:

- The deprecated enable_bookmarking alias no longer silently overrides an
  explicitly supplied bookmark_enable; supplying both now errors.
- Add tests for the deprecation warning, the conflict error, and $app_obj()'s
  enableBookmarking resolution (bookmark_store + bookmark_enable -> effective
  store).
…r method

Table cards called executor$test_query(value), but the QueryExecutor's
test_query() requires a table_name (used to dispatch per-source and check
require_all_columns), so an add with no named table raised "missing
subscript". Card queries can JOIN across tables, so there is no single
table to name.

Add a dedicated QueryExecutor$validate_query(query) that just confirms a
query runs (fetch 1 row, no table/column semantics) and point the table
card branch at it. DuckDBExecutor sends + fetches one row;
DataSourceExecutor delegates to the primary source's test_query().

Test fixtures passed a bare DataFrameSource (whose test_query() has no
table_name arg), masking the production signature. Add
local_query_executor() and run the card tests against a real executor so
this drift is caught.
The QueryExecutor rebase renamed tool_card()'s first argument to
`executor` and dropped the old `<DataSource>` guard, leaving a bad first
argument to surface as a confusing downstream error.

Add check_query_executor() (mirroring check_data_source()) and call it
first in tool_card() so an invalid executor fails with a clear message.
Update the input-check test to pass a real executor when exercising the
manage_card guard, and re-record the snapshot.
The internal mod_server() argument was renamed enable_bookmarking ->
bookmark_enable without an alias (only the public $server() kept a
deprecation shim), but four direct testServer(mod_server, ...) calls
still passed the old name and errored. The file is skip_on_cran, which
hid the failures.
Change the `bookmark_store` default from "url" to NULL on `$app()`,
`$app_obj()`, and `querychat_app()`. NULL means "auto-decide": defer to a
store the app author already set via `shiny::enableBookmarking()`, otherwise
pick a sensible default.

Add `resolve_bookmark_store()` in querychat_module.R, resolving (first match):
disable when no categories; an explicit store; defer (NULL) when
enableBookmarking() is already set; "server" when conversation is bookmarked
(transcript overflows URL limits); "server" on a hosting platform (via
R_CONFIG_ACTIVE); "url" otherwise.

`$server()` continues to set no store itself.
Pull the required-field checks, icon validation, and per-display query
validation out of the LLM tool path into a pure helper, so the URL-restore
and author-seed entry points can share one definition of a valid card.
No behavior change to the tool path.
Add cards_to_payload()/payload_to_cards() (gzip + URL-safe base64),
$cards_url()/$cards_set_url() methods on QueryChat, and a mod_server()
reader that seeds the cards reactive from a namespaced querychat_cards
query param at session start. URL-seeded cards take precedence over
bookmark-restored cards.
Add a cards arg to QueryChat\$new() and querychat(), accepting a list of
card field-lists, a JSON string, or a path to a .json file. normalize_seed_cards()
does structural checks at construction; mod_server() runs full
validate_and_build_card() validation (loud, indexed) once the executor is
available and uses the result as the initial cards reactive value. A URL
param or bookmark restore still overwrites the seed.
…ature C)

Render an "open in new tab" link at the bottom of the Insights panel that
points at \$cards_url() for the current cards. Only shown when cards are
enabled and the dashboard is non-empty; rebuilds reactively as cards change.
Regenerate man pages for \$cards_url()/\$cards_set_url() and the cards seed
arg, add a NEWS entry, and apply air formatting.
gadenbuie and others added 9 commits June 26, 2026 14:10
querychat_app()/$app_obj() now selects the Insights tab on startup
whenever cards are present, regardless of how they were seeded (author
cards= arg, ?querychat_cards= URL param, or bookmark restore). An
onFlushed(once) check fires after the first reactive flush, by which
point all three seed paths have applied.
Value boxes now support full_screen = TRUE. When expanded, a read-only
SQL code editor appears below the value so users can inspect the query
behind the stat. The editor is hidden in normal mode via a CSS rule on
the bslib data-full-screen attribute.
Mechanical rename — no behavior change. The overloaded `value` field is
replaced by `query` (SQL/ggsql data for table/visualization/value_box)
and `text` (markdown body for markdown display). Per-display
required-field validation now checks `query` or `text` as appropriate.
Value box queries can now return multiple columns in a single row.
The displayed number comes from the `value` column (or the first column
if no `value` column). Columns named `title`, `caption`, `theme`, or
`icon` override the static card fields, enabling dynamic theming
(e.g. CASE WHEN ... THEN 'danger' ELSE 'success' END AS theme).

Add-time validation checks returned theme/icon values. Render-time
column-mapping uses a col_or() helper that treats NA as absent,
falling back to the static card fields.
Markdown cards can now optionally include a `query` field (SQL returning
exactly 1 row) whose columns become {{var}} placeholders in the `text`
body, rendered via whisker::whisker.render(). When a query is present,
the card shows a tabbed view (content + SQL code tab) matching the
table/visualization pattern. Static markdown (no query) is unchanged.
…fix card API gaps

- Merge `caption` field into `text`: single field now serves as body
  (markdown), footer (table/viz), or subtitle (value_box)
- Drop theme validation — bslib accepts any theme string as a CSS class
- Rename `data_source` parameter to `executor` in render_card functions
- Fix `querychat_app()` not passing `cards` to `QueryChat$new()`
- Fix stale `@param cards` docstring referencing removed `value` field
- Update tool descriptions and prompts (R and Python) for new schema
- Add `migrate_card_fields()` shim: copies `caption` -> `text` and
  `value` -> `query` for cards persisted under the old schema
- Apply migration in both `validate_and_build_card()` (seed/URL cards)
  and `render_card()` (bookmark-restored cards)
- Update Python tool prompt to describe theme values as examples, not
  an exhaustive set
- Regenerate Rd documentation
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.

1 participant