From 93c06f2c4f44289969c3c4be2f70df3796c833e8 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 22 Jun 2026 15:55:56 -0400 Subject: [PATCH 1/4] fix(pkg-r): freeze viz tool display tags for bookmark serialization The querychat_visualize tool embeds live bslib/htmltools tags (notably a bslib::input_code_editor() in the footer) in the ContentToolResult extra$display. shinychat bookmarks chat turns via jsonlite::serializeJSON() and restores them with unserializeJSON(), which rebuilds embedded closures from deparsed text and drops their environment. The render hooks on those tags then cannot reach bslib internals, so restoring a bookmark that contains a visualization fails with "could not find function tag_require_client_side". Add freeze_tags() to resolve render hooks to inert HTML plus html_dependency objects up front -- while the hooks can still reach those internals -- and apply it to the visualize display html and footer. The frozen output carries the code-editor dependencies through the round-trip, so the component still hydrates on restore. See posit-dev/shinychat#261. --- pkg-r/R/querychat_viz.R | 4 ++-- pkg-r/R/utils-html.R | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 pkg-r/R/utils-html.R diff --git a/pkg-r/R/querychat_viz.R b/pkg-r/R/querychat_viz.R index 980dc96a..cddd49be 100644 --- a/pkg-r/R/querychat_viz.R +++ b/pkg-r/R/querychat_viz.R @@ -143,13 +143,13 @@ visualize_result <- function( ) extra <- list( display = list( - html = viz_container, + html = freeze_tags(viz_container), title = if (nzchar(title)) title else "Query Visualization", show_request = FALSE, open = querychat_tool_starts_open("visualize"), full_screen = TRUE, icon = viz_icon(), - footer = footer + footer = freeze_tags(footer) ) ) diff --git a/pkg-r/R/utils-html.R b/pkg-r/R/utils-html.R new file mode 100644 index 00000000..2ef05b29 --- /dev/null +++ b/pkg-r/R/utils-html.R @@ -0,0 +1,22 @@ +# Resolve a tag's render hooks now and return inert HTML plus its html +# dependencies. shinychat bookmarks chat turns with jsonlite::serializeJSON() +# and restores them with unserializeJSON(), which rebuilds any embedded closure +# from deparsed text and drops its environment. A live bslib/htmltools tag (e.g. +# input_code_editor()) carries render hooks that close over package internals, +# so after the round-trip re-rendering fails with "could not find function". +# Freezing here -- while the hooks can still reach those internals -- keeps only +# inert HTML and html_dependency objects, which survive the round-trip. +# See posit-dev/shinychat#261. +freeze_tags <- function(tags) { + if (is.null(tags) || is.character(tags)) { + return(tags) + } + rendered <- htmltools::renderTags(tags) + htmltools::tagList( + htmltools::HTML(rendered$html), + if (nzchar(rendered$head)) { + htmltools::tags$head(htmltools::HTML(rendered$head)) + }, + rendered$dependencies + ) +} From 52262e4f2cd69d482421cacd140c434512db6127 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 22 Jun 2026 15:55:56 -0400 Subject: [PATCH 2/4] fix(pkg-r): rebuild viz widget bookmark state from URL-decoded data.frame A URL bookmark round-trips querychat_viz_widgets through jsonlite, which simplifies the JSON array of objects to a data.frame. restore_viz_widgets() then iterated its columns (atomic vectors) and failed with "$ operator is invalid for atomic vectors", so the transcript chart never re-rendered. Add restore_record_list() to rebuild the list-of-lists shape row by row and wrap the querychat_viz_widgets restore with it. --- pkg-r/R/querychat_module.R | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/pkg-r/R/querychat_module.R b/pkg-r/R/querychat_module.R index ef668448..ba373d56 100644 --- a/pkg-r/R/querychat_module.R +++ b/pkg-r/R/querychat_module.R @@ -210,7 +210,7 @@ mod_server <- function( if (!is.null(state$values$querychat_viz_widgets)) { restored <- restore_viz_widgets( data_source, - state$values$querychat_viz_widgets, + restore_record_list(state$values$querychat_viz_widgets), session ) viz_widgets <<- restored @@ -234,6 +234,27 @@ GREETING_PROMPT <- paste( "using the suggestion card format from your instructions." ) +# A list of records (named lists) bookmarked to the URL comes back from Shiny's +# decoder as a data.frame, because jsonlite simplifies a JSON array of objects +# (simplifyDataFrame = TRUE). Rebuild the list-of-lists shape row by row, +# dropping absent (NA) optional fields. A value restored from a server-side +# store (or already a list) is passed through unchanged. +restore_record_list <- function(x) { + if (is.null(x)) { + return(NULL) + } + if (is.data.frame(x)) { + return(lapply(seq_len(nrow(x)), function(i) { + row <- as.list(x[i, , drop = FALSE]) + row <- lapply(row, function(v) { + if (length(v) == 1 && is.na(v)) NULL else v + }) + row[!vapply(row, is.null, logical(1))] + })) + } + as.list(x) +} + restore_viz_widgets <- function(data_source, saved_widgets, session) { if (!rlang::is_installed("ggsql")) { warning( From 1d96901196ecb7bcb2c276207ab9ef4d019e08fe Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 23 Jun 2026 10:13:03 -0400 Subject: [PATCH 3/4] fix(pkg-r): warn when URL bookmarking may corrupt viz restore The visualize tool embeds a base64 PNG preview in the tool result for LLM feedback. Under URL bookmarking this can push the bookmarked state past URL length limits and corrupt it on restore. Emit a one-time, best-effort warning recommending server-side bookmarking; detection and the warning are wrapped so they never interrupt chart creation. --- pkg-r/R/querychat_viz.R | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/pkg-r/R/querychat_viz.R b/pkg-r/R/querychat_viz.R index cddd49be..0c73aef5 100644 --- a/pkg-r/R/querychat_viz.R +++ b/pkg-r/R/querychat_viz.R @@ -79,6 +79,7 @@ visualize_result <- function( viz_container <- NULL if (!is.null(session)) { + warn_viz_url_bookmarking() session$output[[widget_id]] <- ggsql::renderGgsql(spec) viz_container <- htmltools::div( class = "querychat-viz-container", @@ -156,6 +157,34 @@ visualize_result <- function( ellmer::ContentToolResult(value = value, extra = extra) } +# Best-effort, additive-only check: with URL bookmarking each chart embeds a +# base64 PNG preview (for LLM feedback) that can push the bookmarked state past +# URL length limits and corrupt it on restore. Recommend server-side +# bookmarking, which has no such limit. Detection and the warning are wrapped so +# this never interrupts chart creation, and the warning fires only once. +warn_viz_url_bookmarking <- function() { + is_url <- tryCatch( + identical(shiny::getShinyOption("bookmarkStore", ""), "url"), + error = function(e) FALSE + ) + if (!isTRUE(is_url)) { + return(invisible(FALSE)) + } + tryCatch( + cli::cli_warn( + c( + "URL bookmarking may not reliably restore visualizations.", + "i" = "Each chart embeds a PNG preview that can exceed URL length limits and corrupt the saved state on restore.", + "i" = "Consider server-side bookmarking instead: {.code shiny::enableBookmarking(\"server\")}." + ), + .frequency = "once", + .frequency_id = "querychat_viz_url_bookmarking" + ), + error = function(e) NULL + ) + invisible(TRUE) +} + collapse_validation_errors <- function(validated) { errors <- validated$errors if (is.null(errors) || !nrow(errors)) { From a78cfd249b1fadcaf9d597f95e9ed14630b80e81 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 23 Jun 2026 10:13:10 -0400 Subject: [PATCH 4/4] fix(pkg-py): warn when URL bookmarking may corrupt viz restore The visualize tool embeds a base64 PNG preview in the tool result for LLM feedback. Under URL bookmarking this can push the bookmarked state past URL length limits and corrupt it on restore. Emit a best-effort warning recommending server-side bookmarking; detection and the warning are wrapped so they never interrupt chart creation. --- pkg-py/src/querychat/_viz_tools.py | 32 ++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/pkg-py/src/querychat/_viz_tools.py b/pkg-py/src/querychat/_viz_tools.py index 19833eb7..6fc4c591 100644 --- a/pkg-py/src/querychat/_viz_tools.py +++ b/pkg-py/src/querychat/_viz_tools.py @@ -5,6 +5,7 @@ import base64 import copy import io +import warnings from typing import TYPE_CHECKING, Any, TypedDict from uuid import uuid4 @@ -146,6 +147,35 @@ def __init__( # --------------------------------------------------------------------------- +def warn_viz_url_bookmarking() -> bool: + """ + Warn (once) when URL bookmarking is active. + + With URL bookmarking each chart embeds a base64 PNG preview (for LLM + feedback) that can push the bookmarked state past URL length limits and + corrupt it on restore. Server-side bookmarking has no such limit. This + check is best-effort and must never interrupt chart creation. + """ + try: + session = get_current_session() + is_url = session is not None and session.bookmark.store == "url" + except Exception: + return False + if not is_url: + return False + try: + warnings.warn( + "URL bookmarking may not reliably restore visualizations. Each " + "chart embeds a PNG preview that can exceed URL length limits and " + "corrupt the saved state on restore. Consider server-side " + 'bookmarking instead: App(..., bookmark_store="server").', + stacklevel=2, + ) + except Exception: + return False + return True + + def visualize_impl( data_source: DataSource, update_fn: Callable[[VisualizeData], None], @@ -183,6 +213,8 @@ def visualize( except Exception: png_bytes = None + warn_viz_url_bookmarking() + update_fn( {"ggsql": ggsql, "title": title, "widget_id": altair_widget.widget_id} )