From 7e0d6d02b7732b177af5f9dff9f7d41829db5c3c Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 26 Aug 2026 16:19:02 -0500 Subject: [PATCH 1/5] Use bslib tooltips for question-entry provenance pills The question list is server-rendered HTML outside shinychat's React tree, so bslib (Popper, container = body) can own tooltip positioning there. commons_answer_pill() gains a tooltip argument; the transcript keeps the inline CSS tooltip for now since bslib tooltips read template.content, which React-rendered message content never populates. --- pkg-r/R/trajectory-review.R | 19 ++++++++++++++----- pkg-r/tests/testthat/test-trajectory-review.R | 13 +++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/pkg-r/R/trajectory-review.R b/pkg-r/R/trajectory-review.R index bcc293e..d31c2fe 100644 --- a/pkg-r/R/trajectory-review.R +++ b/pkg-r/R/trajectory-review.R @@ -346,23 +346,32 @@ append_provenance_pill <- function(content, pill) { c(content, list(pill)) } -commons_answer_pill <- function(tag) { +# The transcript renders inside shinychat, where the CSS-only tooltip gets +# clipped near pane edges and commons-chat.js nudges it back inside. The +# question list is ordinary server-rendered HTML, so there a bslib tooltip +# (Popper, container = body) positions itself without the JS fix. +commons_answer_pill <- function(tag, tooltip = c("inline", "bslib")) { entry <- provenance_display[[tag]] if (is.null(entry)) { return(NULL) } - htmltools::tags$span( + tooltip <- match.arg(tooltip) + pill <- htmltools::tags$span( class = paste0( "commons-answer-pill commons-answer-pill-", entry$pill_class ), - title = entry$body, + title = if (tooltip == "inline") entry$body, `aria-label` = paste0(entry$label, ". ", entry$body), tabindex = "0", commons_pill_icon(entry$icon, entry$label), htmltools::tags$span(entry$label), - commons_pill_tooltip(entry$body) + if (tooltip == "inline") commons_pill_tooltip(entry$body) ) + if (tooltip == "bslib") { + return(bslib::tooltip(pill, entry$body)) + } + pill } commons_pill_tooltip <- function(text) { @@ -929,7 +938,7 @@ question_entry <- function( htmltools::div( class = "commons-viewer-entry-meta", flag_marker(flagged), - commons_answer_pill(record$tag) + commons_answer_pill(record$tag, tooltip = "bslib") ) ) ) diff --git a/pkg-r/tests/testthat/test-trajectory-review.R b/pkg-r/tests/testthat/test-trajectory-review.R index 430a7af..a690d33 100644 --- a/pkg-r/tests/testthat/test-trajectory-review.R +++ b/pkg-r/tests/testthat/test-trajectory-review.R @@ -107,6 +107,19 @@ test_that("provenance markers describe trusted, cited, and uncited answers", { expect_match(uncited, "commons-answer-pill-caution") }) +test_that("bslib pill tooltip defers positioning to bslib", { + skip_if_not_installed("bslib") + skip_if_not_installed("htmltools") + + html <- htmltools::renderTags(commons_answer_pill("A", tooltip = "bslib"))$html + + expect_match(html, "bslib-tooltip", fixed = TRUE) + expect_match(html, "governed calculation") + # No CSS-only tooltip span or title fallback: bslib owns the tooltip. + expect_no_match(html, "commons-tooltip") + expect_no_match(html, "title=") +}) + test_that("trajectory messages render provenance and strip unsafe markup", { skip_if_not_installed("shinychat") skip_if_not_installed("htmltools") From a46e4a349f7c4b73e306a0268f49a8db4521711f Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 26 Aug 2026 16:57:56 -0500 Subject: [PATCH 2/5] Replay the live chat's provenance marker in review transcripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trajectory review rebuilt provenance markers as a custom pill with a hand-rolled CSS tooltip, plus commons-chat.js to nudge the tooltip back inside the chat pane when it clipped. shinychat already solves this: its aside popover opens on hover/focus/click and is positioned by floating-ui through a portal, escaping clipping ancestors entirely. add_message_provenance() now appends the same string the live chat streams, as plain markdown joined with a blank line — the shape contents_shinychat() produces for a live-streamed aside — so the client's aside grouping turns it into the same marker users saw. A new include_cited flag on provenance_aside() keeps a visible marker for Cited answers in the review, which the live chat deliberately leaves bare. Deleted: commons-chat.js, commons_pill_tooltip(), and the .commons-tooltip CSS. Mixed message content (tool cards plus text) is flattened to a single markdown string per message: shinychat's static chat_ui() serializer folds mixed lists into one raw-HTML island, escaping the text and hiding it from aside grouping. The flattening keeps tool cards as routable custom elements; a shinychat-side fix would let us drop it. --- pkg-r/R/chat.R | 1 - pkg-r/R/provenance.R | 7 +- pkg-r/R/trajectory-review.R | 77 +++++++++++------- pkg-r/inst/www/commons-chat/commons-chat.css | 62 --------------- pkg-r/inst/www/commons-chat/commons-chat.js | 78 ------------------- pkg-r/tests/testthat/test-chat.R | 3 +- pkg-r/tests/testthat/test-provenance.R | 4 + pkg-r/tests/testthat/test-trajectory-review.R | 43 ++++++---- 8 files changed, 87 insertions(+), 188 deletions(-) delete mode 100644 pkg-r/inst/www/commons-chat/commons-chat.js diff --git a/pkg-r/R/chat.R b/pkg-r/R/chat.R index 4f27e74..206490c 100644 --- a/pkg-r/R/chat.R +++ b/pkg-r/R/chat.R @@ -184,7 +184,6 @@ commons_chat_dependency <- function() { version = paste0("0.0.0.9000.", as.integer(stamp)), src = c(file = src), stylesheet = "commons-chat.css", - script = "commons-chat.js", all_files = TRUE ) } diff --git a/pkg-r/R/provenance.R b/pkg-r/R/provenance.R index c56f05e..70f11c9 100644 --- a/pkg-r/R/provenance.R +++ b/pkg-r/R/provenance.R @@ -37,9 +37,12 @@ derive_provenance_tag <- function(tags, verified) { } } -provenance_aside <- function(tag) { +# Live answers omit the "Cited" marker: their verified citation asides +# already say as much. Review contexts set include_cited = TRUE so every +# classified answer carries its outcome. +provenance_aside <- function(tag, include_cited = FALSE) { entry <- provenance_display[[tag]] - if (is.null(entry) || identical(tag, "B")) { + if (is.null(entry) || (identical(tag, "B") && !include_cited)) { return("") } icon <- commons_icon_url(entry$icon) diff --git a/pkg-r/R/trajectory-review.R b/pkg-r/R/trajectory-review.R index d31c2fe..71599bd 100644 --- a/pkg-r/R/trajectory-review.R +++ b/pkg-r/R/trajectory-review.R @@ -241,10 +241,40 @@ trajectory_exchange_messages <- function(turns, exchange) { messages <- shinychat::contents_shinychat(chat) for (i in seq_along(messages)) { messages[[i]]$exchange <- as.integer(exchange) + messages[[i]]$content <- flatten_message_content(messages[[i]]$content) } messages } +# shinychat's static chat_ui() serialization folds mixed content (tool cards +# plus markdown text) into a single raw-HTML island, escaping the text and +# hiding it from aside grouping. Flatten to one markdown string instead: tool +# cards serialize to custom elements the markdown pipeline routes anyway. +# If shinychat ever keeps strings as markdown in mixed content (as its +# chat_ui() docs already promise), this workaround can be removed. +# Caveats: htmlwidget deps in message content are not collected, and thinking +# blocks lose their styling. +flatten_message_content <- function(content) { + if (is.character(content)) { + return(paste(content, collapse = "\n")) + } + # Leave classed content (shiny tags, HTML) to shinychat's own serialization. + if (!is.list(content) || is.object(content)) { + return(content) + } + parts <- vapply( + content, + function(x) { + if (is.character(x)) { + return(x) + } + as.character(htmltools::renderTags(htmltools::as.tags(x))[["html"]]) + }, + character(1) + ) + paste(parts, collapse = "\n\n") +} + sanitize_trajectory_turns <- function(turns) { sanitized <- lapply(turns, function(turn) { if (turn@role %in% c("assistant", "user")) { @@ -315,8 +345,11 @@ add_message_provenance <- function(messages, provenance) { } else { record$provenance_tag %||% NA_character_ } - pill <- commons_answer_pill(tag) - if (is.null(pill)) { + # The transcript replays what the live chat streamed: the same + # marker, grouped and positioned by shinychat itself. + # Reviewers also get a "Cited" marker the live chat omits. + aside <- provenance_aside(tag, include_cited = TRUE) + if (!nzchar(aside)) { next } candidates <- which(vapply( @@ -331,51 +364,41 @@ add_message_provenance <- function(messages, provenance) { next } index <- candidates[[length(candidates)]] - messages[[index]]$content <- append_provenance_pill( + messages[[index]]$content <- append_provenance_aside( messages[[index]]$content, - pill + aside ) } messages } -append_provenance_pill <- function(content, pill) { - if (is.character(content)) { - return(list(content, pill)) - } - c(content, list(pill)) +# The aside must stay markdown, not HTML: htmltools::HTML() would mark the +# whole message as a raw-HTML island, which shinychat's aside grouping does +# not reach into. Plain text joined with a blank line is also exactly how a +# live-streamed aside lands in contents_shinychat(). Message content is +# always a single markdown string here thanks to flatten_message_content(). +append_provenance_aside <- function(content, aside) { + paste(content, aside, sep = "\n\n") } -# The transcript renders inside shinychat, where the CSS-only tooltip gets -# clipped near pane edges and commons-chat.js nudges it back inside. The -# question list is ordinary server-rendered HTML, so there a bslib tooltip -# (Popper, container = body) positions itself without the JS fix. -commons_answer_pill <- function(tag, tooltip = c("inline", "bslib")) { +# The question list is ordinary server-rendered HTML outside shinychat's +# React tree, so a bslib tooltip (Popper, container = body) owns positioning. +commons_answer_pill <- function(tag) { entry <- provenance_display[[tag]] if (is.null(entry)) { return(NULL) } - tooltip <- match.arg(tooltip) pill <- htmltools::tags$span( class = paste0( "commons-answer-pill commons-answer-pill-", entry$pill_class ), - title = if (tooltip == "inline") entry$body, `aria-label` = paste0(entry$label, ". ", entry$body), tabindex = "0", commons_pill_icon(entry$icon, entry$label), - htmltools::tags$span(entry$label), - if (tooltip == "inline") commons_pill_tooltip(entry$body) + htmltools::tags$span(entry$label) ) - if (tooltip == "bslib") { - return(bslib::tooltip(pill, entry$body)) - } - pill -} - -commons_pill_tooltip <- function(text) { - htmltools::tags$span(class = "commons-tooltip", role = "tooltip", text) + bslib::tooltip(pill, entry$body) } commons_pill_icon <- function(file, alt) { @@ -938,7 +961,7 @@ question_entry <- function( htmltools::div( class = "commons-viewer-entry-meta", flag_marker(flagged), - commons_answer_pill(record$tag, tooltip = "bslib") + commons_answer_pill(record$tag) ) ) ) diff --git a/pkg-r/inst/www/commons-chat/commons-chat.css b/pkg-r/inst/www/commons-chat/commons-chat.css index e8a5fa2..0d9a404 100644 --- a/pkg-r/inst/www/commons-chat/commons-chat.css +++ b/pkg-r/inst/www/commons-chat/commons-chat.css @@ -41,7 +41,6 @@ shiny-chat-container .shiny-chat-input .tiptap { line-height: 1.2; max-width: 100%; padding: 0.18rem 0.45rem; - position: relative; vertical-align: text-bottom; } @@ -69,67 +68,6 @@ shiny-chat-container .shiny-chat-input .tiptap { color: #6b4b1b; } -/* ---- Provenance tooltips ---------------------------------------------- */ - -/* Use a real element so JavaScript can keep tooltips inside the pane. */ -.commons-tooltip { - background: var(--bs-body-bg, #fff); - border: 1px solid var(--bs-border-color, #dee2e6); - border-radius: 6px; - bottom: calc(100% + 0.45rem); - box-shadow: 0 6px 18px rgba(15, 23, 42, 0.12); - color: var(--bs-body-color, #212529); - display: none; - font-size: 0.74rem; - font-weight: 400; - left: 50%; - line-height: 1.25; - max-width: min(18rem, 70vw); - padding: 0.42rem 0.55rem; - pointer-events: none; - position: absolute; - text-align: left; - transform: translateX(calc(-50% + var(--commons-tooltip-shift, 0px))); - white-space: normal; - width: max-content; - z-index: 1000; -} - -.commons-answer-pill:hover > .commons-tooltip, -.commons-answer-pill:focus > .commons-tooltip, -.commons-answer-pill:focus-within > .commons-tooltip { - display: block; -} - -.commons-answer-pill:hover::before, -.commons-answer-pill:focus::before, -.commons-answer-pill:focus-within::before { - background: var(--bs-body-bg, #fff); - border-bottom: 1px solid var(--bs-border-color, #dee2e6); - border-right: 1px solid var(--bs-border-color, #dee2e6); - bottom: calc(100% + 0.28rem); - content: ""; - height: 0.55rem; - left: 50%; - pointer-events: none; - position: absolute; - transform: translateX(-50%) rotate(45deg); - width: 0.55rem; - z-index: 1000; -} - -/* Markers with too little room above open downward instead */ -.commons-tooltip-below > .commons-tooltip { - bottom: auto; - top: calc(100% + 0.45rem); -} - -.commons-answer-pill.commons-tooltip-below::before { - bottom: auto; - top: calc(100% + 0.28rem); - transform: translateX(-50%) rotate(225deg); -} - /* Citations are labeled identity asides: shinychat accumulates those * sharing a paragraph into one pill with a "+N" overflow and a carousel * popover. They render icon-only like the provenance markers, in a quieter diff --git a/pkg-r/inst/www/commons-chat/commons-chat.js b/pkg-r/inst/www/commons-chat/commons-chat.js deleted file mode 100644 index 7840bba..0000000 --- a/pkg-r/inst/www/commons-chat/commons-chat.js +++ /dev/null @@ -1,78 +0,0 @@ -(function() { - var register = function() { - if (!window.Shiny || !Shiny.addCustomMessageHandler) { - window.setTimeout(register, 25); - return; - } - - if (window.commonsAnswerPillTooltipInitialized) return; - window.commonsAnswerPillTooltipInitialized = true; - - // Provenance tooltips are centered above their marker, which leaves them - // hanging outside the chat pane — and clipped by it — when the marker - // sits near an edge. The box has no layout until the marker is hovered - // or focused, so it is measured and nudged back inside then: sideways by - // a shift the arrow deliberately doesn't follow, so the arrow keeps - // pointing at the marker, and vertically by flipping below. - var placeTooltip = function(marker) { - var tip = marker.querySelector(".commons-tooltip"); - if (!tip) return; - - marker.classList.remove("commons-tooltip-below"); - tip.style.setProperty("--commons-tooltip-shift", "0px"); - - var rect = tip.getBoundingClientRect(); - if (!rect.width) return; - - var margin = 8; - var bounds = clipBounds(marker); - var minLeft = bounds.left + margin; - var maxRight = bounds.right - margin; - - var shift = 0; - if (rect.right > maxRight) shift = maxRight - rect.right; - if (rect.left + shift < minLeft) shift = minLeft - rect.left; - tip.style.setProperty("--commons-tooltip-shift", shift + "px"); - - if (rect.top < bounds.top + margin) { - marker.classList.add("commons-tooltip-below"); - } - }; - - // Any scrolling or clipping ancestor can crop the tooltip, not just the - // nearest one: shinychat's message scroller deliberately hangs a - // scroll-margin's worth of padding past the wrapper that clips it, so - // stopping at the scroller still leaves the box cut off on the right. - var clipBounds = function(marker) { - var root = document.documentElement; - var bounds = { left: 0, right: root.clientWidth, top: 0 }; - - for (var node = marker.parentElement; node; node = node.parentElement) { - if (!node.clientWidth && !node.clientHeight) continue; - var style = window.getComputedStyle(node); - var rect = node.getBoundingClientRect(); - if (style.overflowX !== "visible") { - var left = rect.left + node.clientLeft; - bounds.left = Math.max(bounds.left, left); - bounds.right = Math.min(bounds.right, left + node.clientWidth); - } - if (style.overflowY !== "visible") { - bounds.top = Math.max(bounds.top, rect.top + node.clientTop); - } - } - - return bounds; - }; - - var onMarker = function(event) { - if (!event.target || !event.target.closest) return; - var marker = event.target.closest(".commons-answer-pill"); - if (marker) placeTooltip(marker); - }; - - document.addEventListener("pointerover", onMarker, true); - document.addEventListener("focusin", onMarker, true); - }; - - register(); -})(); diff --git a/pkg-r/tests/testthat/test-chat.R b/pkg-r/tests/testthat/test-chat.R index 7d0a62c..0c49ca5 100644 --- a/pkg-r/tests/testthat/test-chat.R +++ b/pkg-r/tests/testthat/test-chat.R @@ -113,7 +113,8 @@ test_that("commons_theme() bundles the commons chat assets", { commons_dep <- deps[[which(names == "commons-chat")]] expect_identical(commons_dep$stylesheet, "commons-chat.css") - expect_identical(commons_dep$script, "commons-chat.js") + # Tooltip positioning is owned by shinychat (asides) and bslib (pills). + expect_null(commons_dep$script) }) test_that("icon URLs resolve inside the commons-chat dependency", { diff --git a/pkg-r/tests/testthat/test-provenance.R b/pkg-r/tests/testthat/test-provenance.R index db39764..8b128af 100644 --- a/pkg-r/tests/testthat/test-provenance.R +++ b/pkg-r/tests/testthat/test-provenance.R @@ -58,4 +58,8 @@ test_that("provenance_aside renders A and C, nothing for B/NA", { expect_identical(provenance_aside("B"), "") expect_identical(provenance_aside(NA_character_), "") + + cited <- provenance_aside("B", include_cited = TRUE) + expect_match(cited, '^ Date: Thu, 27 Aug 2026 18:12:30 -0500 Subject: [PATCH 3/5] Drop redundant aria-label on question-list provenance pills The pill's visible text already announces the label, Bootstrap sets aria-describedby when the bslib tooltip shows the body, and the icon's alt text was a third copy. The icon is now decorative (alt=""). --- pkg-r/R/trajectory-review.R | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg-r/R/trajectory-review.R b/pkg-r/R/trajectory-review.R index 71599bd..dfb4df8 100644 --- a/pkg-r/R/trajectory-review.R +++ b/pkg-r/R/trajectory-review.R @@ -393,9 +393,11 @@ commons_answer_pill <- function(tag) { "commons-answer-pill commons-answer-pill-", entry$pill_class ), - `aria-label` = paste0(entry$label, ". ", entry$body), + # No aria-label: the visible text already announces the label, and + # Bootstrap sets aria-describedby when the tooltip shows the body. tabindex = "0", - commons_pill_icon(entry$icon, entry$label), + # Decorative: the adjacent span already names the label + commons_pill_icon(entry$icon, ""), htmltools::tags$span(entry$label) ) bslib::tooltip(pill, entry$body) From 028e190640e61b14109404bfa036eff312513a70 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 27 Aug 2026 18:12:30 -0500 Subject: [PATCH 4/5] Update demo deploy manifest for the new commons-chat assets commons-chat.js is gone and the figs/ SVGs moved under www/commons-chat; refresh the files block and checksums accordingly. A full writeManifest() regeneration needs an environment with GitHub-sourced ellmer/shiny/shinychat installs; the packages section is unchanged. --- pkg-r/inst/manifest.json | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/pkg-r/inst/manifest.json b/pkg-r/inst/manifest.json index 8d8e0c9..ea34537 100644 --- a/pkg-r/inst/manifest.json +++ b/pkg-r/inst/manifest.json @@ -2533,13 +2533,28 @@ }, "files": { "app.R": { - "checksum": "62078c5a9372fa4b817dfffd20433d9e" + "checksum": "b86881fd81fa7e0826066b09f7f22818" }, "www/commons-chat/commons-chat.css": { - "checksum": "a860c7bf65906fae00195204b2964a44" + "checksum": "09486e2b6df927713f09ef448c7b6e66" }, - "www/commons-chat/commons-chat.js": { - "checksum": "09f5a710fec03d91711010e2557e1115" + "www/commons-chat/figs/citation-definition.svg": { + "checksum": "eeec1c079ecac49ca50a1b89092d2b98" + }, + "www/commons-chat/figs/citation-mark.svg": { + "checksum": "828219c3a6d441b3aa4f03073df4949b" + }, + "www/commons-chat/figs/citation-prose.svg": { + "checksum": "ee624ea71c7f023becbadd75c934ed26" + }, + "www/commons-chat/figs/citation-schema.svg": { + "checksum": "9ca6eb80eea77978c03cf0df422298ac" + }, + "www/commons-chat/figs/trusted-icon.svg": { + "checksum": "fc93941fc788e8930bea0c177161c6a6" + }, + "www/commons-chat/figs/warning-icon.svg": { + "checksum": "aeca364302762c1097ed473a32338df6" } }, "users": null From 811a46507098ccf3863841c998f58b16d8ff6351 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 27 Aug 2026 18:44:27 -0500 Subject: [PATCH 5/5] Replay review transcripts through chat_append instead of static chat_ui Static chat_ui(messages =) folds mixed content (tool cards plus markdown text) into a single raw-HTML island, escaping the text and hiding it from shinychat's aside grouping. Mirror shinychat's own bookmark-restore idiom (client_set_ui): yield each message's contents from a coro generator through chat_append(), so every chunk is classified markdown vs HTML exactly as in the live stream. This drops the flatten_message_content() workaround along with its caveats (uncollected htmlwidget dependencies, unstyled thinking blocks), and the provenance aside now lands as a trailing markdown chunk, matching how a live-streamed aside arrives. --- pkg-r/R/trajectory-review.R | 82 ++++++++++--------- pkg-r/tests/testthat/test-trajectory-review.R | 47 +++++++---- 2 files changed, 76 insertions(+), 53 deletions(-) diff --git a/pkg-r/R/trajectory-review.R b/pkg-r/R/trajectory-review.R index dfb4df8..f14c687 100644 --- a/pkg-r/R/trajectory-review.R +++ b/pkg-r/R/trajectory-review.R @@ -241,38 +241,37 @@ trajectory_exchange_messages <- function(turns, exchange) { messages <- shinychat::contents_shinychat(chat) for (i in seq_along(messages)) { messages[[i]]$exchange <- as.integer(exchange) - messages[[i]]$content <- flatten_message_content(messages[[i]]$content) } messages } -# shinychat's static chat_ui() serialization folds mixed content (tool cards -# plus markdown text) into a single raw-HTML island, escaping the text and -# hiding it from aside grouping. Flatten to one markdown string instead: tool -# cards serialize to custom elements the markdown pipeline routes anyway. -# If shinychat ever keeps strings as markdown in mixed content (as its -# chat_ui() docs already promise), this workaround can be removed. -# Caveats: htmlwidget deps in message content are not collected, and thinking -# blocks lose their styling. -flatten_message_content <- function(content) { - if (is.character(content)) { - return(paste(content, collapse = "\n")) - } - # Leave classed content (shiny tags, HTML) to shinychat's own serialization. - if (!is.list(content) || is.object(content)) { - return(content) +# Replay the transcript the way shinychat itself restores a bookmarked chat +# (client_set_ui()): yield each message's contents from a generator through +# chat_append(), so every item is classified on its own (markdown vs HTML) +# exactly as in the live stream. Static chat_ui(messages = ...) would fold +# mixed content (tool cards plus markdown text) into a single raw-HTML +# island, escaping the text and hiding it from aside grouping. +replay_transcript <- function(id, messages, session) { + for (message in messages) { + replay_transcript_message(id, message, session) } - parts <- vapply( - content, - function(x) { - if (is.character(x)) { - return(x) + invisible(NULL) +} + +# One function call per message so the generator closes over this message's +# content; coro generators evaluate their body lazily. +replay_transcript_message <- function(id, message, session) { + content <- message$content + if (is.list(content) && !is.object(content)) { + stream <- coro::generator(function() { + for (x in content) { + coro::yield(x) } - as.character(htmltools::renderTags(htmltools::as.tags(x))[["html"]]) - }, - character(1) - ) - paste(parts, collapse = "\n\n") + }) + shinychat::chat_append(id, stream(), role = message$role, session = session) + } else { + shinychat::chat_append(id, content, role = message$role, session = session) + } } sanitize_trajectory_turns <- function(turns) { @@ -373,12 +372,18 @@ add_message_provenance <- function(messages, provenance) { } # The aside must stay markdown, not HTML: htmltools::HTML() would mark the -# whole message as a raw-HTML island, which shinychat's aside grouping does -# not reach into. Plain text joined with a blank line is also exactly how a -# live-streamed aside lands in contents_shinychat(). Message content is -# always a single markdown string here thanks to flatten_message_content(). +# chunk as raw HTML, which shinychat's aside grouping does not reach into. +# A trailing markdown chunk is also exactly how a live-streamed aside lands: +# replay_transcript() yields each content item on its own, and the client +# folds consecutive markdown chunks into one block. append_provenance_aside <- function(content, aside) { - paste(content, aside, sep = "\n\n") + if (is.character(content)) { + return(paste(paste(content, collapse = "\n"), aside, sep = "\n\n")) + } + if (is.list(content) && !is.object(content)) { + return(c(content, list(aside))) + } + list(content, aside) } # The question list is ordinary server-rendered HTML outside shinychat's @@ -797,23 +802,24 @@ viewer_server <- function( "Select a conversation to view its transcript." )) } - shinychat::chat_ui( - transcript_id(conversation), - messages = selected_messages(), - height = "100%" - ) + # Messages are replayed from the server once the element is bound; + # see replay_transcript() for why they don't go through chat_ui(). + shinychat::chat_ui(transcript_id(conversation), height = "100%") }) - # Seed decorations only after the new chat element is bound in the browser. + # Replay and seed decorations only after the new chat element is bound in + # the browser. Replay first so the seed's message indices line up. shiny::observeEvent(selected_conversation(), { conversation <- selected_conversation() exchange <- selected_exchange() messages <- selected_messages() session$onFlushed( function() { + id <- transcript_id(conversation) + replay_transcript(id, messages, session) seed_transcript_decorations( session, - transcript_id(conversation), + id, messages, selected_exchange = exchange ) diff --git a/pkg-r/tests/testthat/test-trajectory-review.R b/pkg-r/tests/testthat/test-trajectory-review.R index e241a37..072a3e4 100644 --- a/pkg-r/tests/testthat/test-trajectory-review.R +++ b/pkg-r/tests/testthat/test-trajectory-review.R @@ -17,6 +17,22 @@ provenance_record <- function(tag, citation_decisions = list()) { list(provenance_tag = tag, citation_decisions = citation_decisions) } +# The markdown-bearing parts of replayed message content. Rendered tool-card +# tags are not included; the markup-safety assertions only exercise text. +messages_text <- function(messages) { + parts <- lapply(messages, function(m) { + content <- m$content + if (is.character(content)) { + return(content) + } + if (is.list(content) && !is.object(content)) { + return(unlist(Filter(is.character, content))) + } + character() + }) + paste(unlist(parts), collapse = "\n") +} + test_that("split_exchanges opens at plain user turns only", { turns <- c( list( @@ -143,16 +159,20 @@ test_that("trajectory messages render provenance and strip unsafe markup", { expect_identical(attr(sanitized, "provenance"), attr(turns, "provenance")) messages <- trajectory_messages(sanitized) - html <- as.character(shinychat::chat_ui( - "transcript", - messages = messages - )) - expect_match(html, "6 orders.", fixed = TRUE) - # The transcript replays the live chat's provenance marker (escaped - # inside the message content attribute; the client unescapes it). - expect_match(html, "shiny-aside label="Verified answer"", fixed = TRUE) - expect_no_match(html, "commons-citation", fixed = TRUE) - expect_no_match(html, "Forged", fixed = TRUE) + text <- messages_text(messages) + expect_match(text, "6 orders.", fixed = TRUE) + expect_no_match(text, "commons-citation", fixed = TRUE) + expect_no_match(text, "Forged", fixed = TRUE) + + # The transcript replays the live chat's provenance marker: a trailing + # markdown chunk on the exchange's last assistant message. + assistant <- Filter( + \(m) identical(m$role, "assistant") && identical(m$exchange, 1L), + messages + ) + last <- assistant[[length(assistant)]]$content + trailing <- if (is.character(last)) last else last[[length(last)]] + expect_match(trailing, '