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 bcc293e..f14c687 100644 --- a/pkg-r/R/trajectory-review.R +++ b/pkg-r/R/trajectory-review.R @@ -245,6 +245,35 @@ trajectory_exchange_messages <- function(turns, exchange) { messages } +# 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) + } + 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) + } + }) + 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) { sanitized <- lapply(turns, function(turn) { if (turn@role %in% c("assistant", "user")) { @@ -315,8 +344,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,42 +363,49 @@ 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) { +# The aside must stay markdown, not HTML: htmltools::HTML() would mark the +# 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) { if (is.character(content)) { - return(list(content, pill)) + return(paste(paste(content, collapse = "\n"), aside, sep = "\n\n")) } - c(content, list(pill)) + 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 +# 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) } - htmltools::tags$span( + pill <- htmltools::tags$span( class = paste0( "commons-answer-pill commons-answer-pill-", entry$pill_class ), - title = entry$body, - `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), - htmltools::tags$span(entry$label), - commons_pill_tooltip(entry$body) + # Decorative: the adjacent span already names the label + commons_pill_icon(entry$icon, ""), + htmltools::tags$span(entry$label) ) -} - -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) { @@ -763,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/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 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, '^