From 58763e34fcc0aac8d4157efd9e92fd32414c2fd5 Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Fri, 19 Dec 2025 10:07:21 -0600 Subject: [PATCH 1/6] feat(pkg-r): Support streaming `ContentThinking` --- pkg-r/R/chat.R | 28 ++++- pkg-r/R/contents_shinychat.R | 48 +++++++- pkg-r/inst/lib/shiny/thinking/thinking.css | 48 ++++++++ pkg-r/inst/lib/shiny/thinking/thinking.js | 103 ++++++++++++++++++ .../tests/testthat/test-contents_shinychat.R | 10 ++ 5 files changed, 233 insertions(+), 4 deletions(-) create mode 100644 pkg-r/inst/lib/shiny/thinking/thinking.css create mode 100644 pkg-r/inst/lib/shiny/thinking/thinking.js diff --git a/pkg-r/R/chat.R b/pkg-r/R/chat.R index c73a5f8c..f5acd49e 100644 --- a/pkg-r/R/chat.R +++ b/pkg-r/R/chat.R @@ -12,11 +12,13 @@ chat_deps <- function() { src = "lib/shiny", script = list( list(src = "chat/chat.js", type = "module"), - list(src = "markdown-stream/markdown-stream.js", type = "module") + list(src = "markdown-stream/markdown-stream.js", type = "module"), + list(src = "thinking/thinking.js") ), stylesheet = c( "chat/chat.css", - "markdown-stream/markdown-stream.css" + "markdown-stream/markdown-stream.css", + "thinking/thinking.css" ) ) } @@ -504,6 +506,14 @@ rlang::on_load( chat_append_("", chunk = "start", icon = icon) res <- fastmap::fastqueue(200) + thinking_id <- NULL + + send_thinking <- function(type, content = "") { + session$sendCustomMessage( + "shinychat-thinking", + list(id = thinking_id, type = type, content = content) + ) + } for (msg in stream) { if (promises::is.promising(msg)) { @@ -521,6 +531,16 @@ rlang::on_load( } } + if (S7::S7_inherits(msg, ellmer::ContentThinking)) { + if (is.null(thinking_id)) { + thinking_id <- paste0("think-", format(Sys.time(), "%H%M%S"), "-", sample.int(1e6, 1)) + send_thinking("start", msg@thinking) + } else { + send_thinking("update", msg@thinking) + } + next + } + if (S7::S7_inherits(msg, ellmer::Content)) { msg <- contents_shinychat(msg) } @@ -528,6 +548,10 @@ rlang::on_load( chat_append_(msg) } + if (!is.null(thinking_id)) { + send_thinking("done") + } + chat_append_("", chunk = "end") res <- res$as_list() diff --git a/pkg-r/R/contents_shinychat.R b/pkg-r/R/contents_shinychat.R index 02e2b260..8c339908 100644 --- a/pkg-r/R/contents_shinychat.R +++ b/pkg-r/R/contents_shinychat.R @@ -136,6 +136,26 @@ S7::method(contents_shinychat, ellmer::ContentText) <- function(content) { content@text } +S7::method(contents_shinychat, ellmer::ContentThinking) <- function(content) { + text <- content@thinking + preview <- strsplit(trimws(text), "\n")[[1]][1] + if (nchar(preview) > 60) { + preview <- paste0(substr(preview, 1, 60), "\u2026") + } + + htmltools::div( + class = "shinychat-thinking", + `data-open` = "false", + htmltools::tags$button( + type = "button", + class = "shinychat-thinking-toggle", + `aria-expanded` = "false", + htmltools::span(class = "shinychat-thinking-preview", preview) + ), + htmltools::div(class = "shinychat-thinking-content", text) + ) +} + new_tool_card <- function(type, request_id, tool_name, ...) { type <- arg_match(type, c("request", "result")) @@ -358,8 +378,32 @@ tool_string <- function(x) { S7::method(contents_shinychat, ellmer::Turn) <- function(content) { - # Process all contents in the turn, filtering out empty results - compact(map(content@contents, contents_shinychat)) + contents <- content@contents + + # Consolidate adjacent ContentThinking into single blocks + consolidated <- list() + thinking_buffer <- character() + + + flush_thinking <- function() { + if (length(thinking_buffer) > 0) { + combined <- ellmer::ContentThinking(paste(thinking_buffer, collapse = "\n\n")) + consolidated <<- c(consolidated, list(combined)) + thinking_buffer <<- character() + } + } + + for (item in contents) { + if (S7::S7_inherits(item, ellmer::ContentThinking)) { + thinking_buffer <- c(thinking_buffer, item@thinking) + } else { + flush_thinking() + consolidated <- c(consolidated, list(item)) + } + } + flush_thinking() + + compact(map(consolidated, contents_shinychat)) } S7::method(contents_shinychat, S7::new_S3_class(c("Chat", "R6"))) <- function( diff --git a/pkg-r/inst/lib/shiny/thinking/thinking.css b/pkg-r/inst/lib/shiny/thinking/thinking.css new file mode 100644 index 00000000..fbc11673 --- /dev/null +++ b/pkg-r/inst/lib/shiny/thinking/thinking.css @@ -0,0 +1,48 @@ +.shinychat-thinking { + margin-bottom: 0.75rem; + border-left: 2px solid var(--bs-border-color, #dee2e6); + padding-left: 0.75rem; + font-size: 0.9em; + font-style: italic; + color: var(--bs-secondary-color, #6c757d); + cursor: pointer; +} + +.shinychat-thinking:hover { + color: var(--bs-body-color, #212529); +} + +.shinychat-thinking-toggle { + background: none; + border: none; + padding: 0; + display: flex; + align-items: center; + color: inherit; + font-size: inherit; + font-style: inherit; + text-align: left; + width: 100%; +} + +.shinychat-thinking-preview { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +.shinychat-thinking-content { + display: none; + margin-top: 0.5rem; + white-space: pre-wrap; +} + +.shinychat-thinking[data-open="true"] .shinychat-thinking-preview { + display: none; +} + +.shinychat-thinking[data-open="true"] .shinychat-thinking-content { + display: block; +} diff --git a/pkg-r/inst/lib/shiny/thinking/thinking.js b/pkg-r/inst/lib/shiny/thinking/thinking.js new file mode 100644 index 00000000..32419837 --- /dev/null +++ b/pkg-r/inst/lib/shiny/thinking/thinking.js @@ -0,0 +1,103 @@ +(function() { + const thinkingBlocks = new Map(); + + function findStreamingMessage() { + const messages = document.querySelector('shiny-chat-messages'); + if (!messages) return null; + return messages.querySelector('shiny-chat-message[streaming]'); + } + + function createThinkingContainer(id) { + const wrapper = document.createElement('div'); + wrapper.className = 'shinychat-thinking'; + wrapper.dataset.id = id; + wrapper.dataset.open = 'false'; + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'shinychat-thinking-toggle'; + button.setAttribute('aria-expanded', 'false'); + + const preview = document.createElement('span'); + preview.className = 'shinychat-thinking-preview'; + preview.textContent = '…'; + + button.appendChild(preview); + + const content = document.createElement('div'); + content.className = 'shinychat-thinking-content'; + + wrapper.appendChild(button); + wrapper.appendChild(content); + + return { wrapper, preview, content, fullText: '' }; + } + + function handleThinkingMessage(payload) { + const { id, type, content: text } = payload; + + if (type === 'start') { + const message = findStreamingMessage(); + if (!message) { + setTimeout(() => handleThinkingMessage(payload), 50); + return; + } + + const parts = createThinkingContainer(id); + thinkingBlocks.set(id, parts); + + const target = message.querySelector('shiny-markdown-stream') || message; + target.insertAdjacentElement('afterbegin', parts.wrapper); + } + + if (type === 'update' || type === 'start') { + const parts = thinkingBlocks.get(id); + if (!parts) return; + + if (text) { + parts.fullText += text; + parts.content.textContent = parts.fullText; + + const previewText = parts.fullText.trim().split('\n')[0]; + parts.preview.textContent = previewText.length > 60 + ? previewText.slice(0, 60) + '…' + : previewText || '…'; + } + } + + if (type === 'done') { + const parts = thinkingBlocks.get(id); + if (!parts) return; + + if (!parts.fullText.trim()) { + parts.wrapper.remove(); + thinkingBlocks.delete(id); + } + } + } + + function handleThinkingClick(event) { + const wrapper = event.target.closest('.shinychat-thinking'); + if (!wrapper) return; + + const button = wrapper.querySelector('button'); + const isOpen = wrapper.dataset.open === 'true'; + wrapper.dataset.open = (!isOpen).toString(); + if (button) { + button.setAttribute('aria-expanded', (!isOpen).toString()); + } + } + + function register() { + if (!window.Shiny) return; + Shiny.addCustomMessageHandler('shinychat-thinking', handleThinkingMessage); + } + + document.addEventListener('click', handleThinkingClick); + + if (window.Shiny) { + register(); + } else { + document.addEventListener('shiny:connected', register); + } +})(); diff --git a/pkg-r/tests/testthat/test-contents_shinychat.R b/pkg-r/tests/testthat/test-contents_shinychat.R index 47b38147..913f1c95 100644 --- a/pkg-r/tests/testthat/test-contents_shinychat.R +++ b/pkg-r/tests/testthat/test-contents_shinychat.R @@ -37,6 +37,16 @@ test_that("opt_shinychat_tool_display handles options and environment variables" }) }) +test_that("ContentThinking renders as collapsible HTML", { + thinking <- ellmer::ContentThinking("Let me think about this...") + result <- contents_shinychat(thinking) + + expect_s3_class(result, "html") + expect_match(as.character(result), "
") + expect_match(as.character(result), "Thinking") + expect_match(as.character(result), "Let me think about this") +}) + test_that("basic Content handling works", { ContentHTML <- S7::new_class( "ContentHTML", From a8f5991f53cda757cd26c4731e7f43845e6dad64 Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Fri, 19 Dec 2025 10:53:40 -0600 Subject: [PATCH 2/6] refactor out double assignment --- pkg-r/R/contents_shinychat.R | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/pkg-r/R/contents_shinychat.R b/pkg-r/R/contents_shinychat.R index 8c339908..9bd4153c 100644 --- a/pkg-r/R/contents_shinychat.R +++ b/pkg-r/R/contents_shinychat.R @@ -376,33 +376,33 @@ tool_string <- function(x) { } } +flush_thinking_buffer <- function(thinking_buffer) { + if (length(thinking_buffer) == 0) { + return(list()) + } + combined <- ellmer::ContentThinking(paste(thinking_buffer, collapse = "\n\n")) + list(combined) +} -S7::method(contents_shinychat, ellmer::Turn) <- function(content) { - contents <- content@contents - - # Consolidate adjacent ContentThinking into single blocks +consolidate_thinking <- function(contents) { consolidated <- list() thinking_buffer <- character() - - flush_thinking <- function() { - if (length(thinking_buffer) > 0) { - combined <- ellmer::ContentThinking(paste(thinking_buffer, collapse = "\n\n")) - consolidated <<- c(consolidated, list(combined)) - thinking_buffer <<- character() - } - } - for (item in contents) { if (S7::S7_inherits(item, ellmer::ContentThinking)) { thinking_buffer <- c(thinking_buffer, item@thinking) } else { - flush_thinking() + consolidated <- c(consolidated, flush_thinking_buffer(thinking_buffer)) + thinking_buffer <- character() consolidated <- c(consolidated, list(item)) } } - flush_thinking() + c(consolidated, flush_thinking_buffer(thinking_buffer)) +} + +S7::method(contents_shinychat, ellmer::Turn) <- function(content) { + consolidated <- consolidate_thinking(content@contents) compact(map(consolidated, contents_shinychat)) } From 956ce618831e89d477b9796711373089e9e45d22 Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Fri, 19 Dec 2025 10:54:14 -0600 Subject: [PATCH 3/6] add NEWS entry [no ci] --- pkg-r/NEWS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg-r/NEWS.md b/pkg-r/NEWS.md index 65f63c06..f024a52b 100644 --- a/pkg-r/NEWS.md +++ b/pkg-r/NEWS.md @@ -1,5 +1,7 @@ # shinychat (development version) +* Added support for displaying thinking/reasoning content from models that support extended thinking. Thinking content appears in a collapsible block during streaming and on reload (@simonpcouch, #167). + * Fixed an issue where user chat messages would display the default assistant icon. (#162) # shinychat 0.3.0 From 6baa05b713ede4283b7e6ef84d0a972455a54388 Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Fri, 19 Dec 2025 12:16:46 -0600 Subject: [PATCH 4/6] format with air --- pkg-r/R/chat.R | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg-r/R/chat.R b/pkg-r/R/chat.R index f5acd49e..23f6932f 100644 --- a/pkg-r/R/chat.R +++ b/pkg-r/R/chat.R @@ -533,7 +533,12 @@ rlang::on_load( if (S7::S7_inherits(msg, ellmer::ContentThinking)) { if (is.null(thinking_id)) { - thinking_id <- paste0("think-", format(Sys.time(), "%H%M%S"), "-", sample.int(1e6, 1)) + thinking_id <- paste0( + "think-", + format(Sys.time(), "%H%M%S"), + "-", + sample.int(1e6, 1) + ) send_thinking("start", msg@thinking) } else { send_thinking("update", msg@thinking) From aa31abac7ef4926b9872a970e217be8791966a45 Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Fri, 19 Dec 2025 12:23:56 -0600 Subject: [PATCH 5/6] correct `contents_shinychat()` output expectation --- pkg-r/tests/testthat/_snaps/chat.md | 2 +- pkg-r/tests/testthat/test-contents_shinychat.R | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/pkg-r/tests/testthat/_snaps/chat.md b/pkg-r/tests/testthat/_snaps/chat.md index 8b0eefaf..f451e1e1 100644 --- a/pkg-r/tests/testthat/_snaps/chat.md +++ b/pkg-r/tests/testthat/_snaps/chat.md @@ -55,7 +55,7 @@ "1.0.0", "")), span("world")))) Output $deps - [{"name":"foo","all_files":true},{"name":"shinychat","script":[{"src":"chat/chat.js","type":"module"},{"src":"markdown-stream/markdown-stream.js","type":"module"}],"stylesheet":["chat/chat.css","markdown-stream/markdown-stream.css"],"all_files":true},{"name":"bslib-tag-require","script":"tag-require.js","all_files":true},{"name":"htmltools-fill","stylesheet":"fill.css","all_files":true}] + [{"name":"foo","all_files":true},{"name":"shinychat","script":[{"src":"chat/chat.js","type":"module"},{"src":"markdown-stream/markdown-stream.js","type":"module"},{"src":"thinking/thinking.js"}],"stylesheet":["chat/chat.css","markdown-stream/markdown-stream.css","thinking/thinking.css"],"all_files":true},{"name":"bslib-tag-require","script":"tag-require.js","all_files":true},{"name":"htmltools-fill","stylesheet":"fill.css","all_files":true}] $html diff --git a/pkg-r/tests/testthat/test-contents_shinychat.R b/pkg-r/tests/testthat/test-contents_shinychat.R index 913f1c95..33170af9 100644 --- a/pkg-r/tests/testthat/test-contents_shinychat.R +++ b/pkg-r/tests/testthat/test-contents_shinychat.R @@ -41,10 +41,11 @@ test_that("ContentThinking renders as collapsible HTML", { thinking <- ellmer::ContentThinking("Let me think about this...") result <- contents_shinychat(thinking) - expect_s3_class(result, "html") - expect_match(as.character(result), "
") - expect_match(as.character(result), "Thinking") - expect_match(as.character(result), "Let me think about this") + expect_s3_class(result, "shiny.tag") + html <- as.character(result) + expect_match(html, "shinychat-thinking") + expect_match(html, "shinychat-thinking-content") + expect_match(html, "Let me think about this") }) test_that("basic Content handling works", { From fa4c83f2beef1d0927d4ac2efad0ee8f76e29717 Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Fri, 19 Dec 2025 13:07:37 -0600 Subject: [PATCH 6/6] address codex's review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``` - pkg-r/inst/lib/shiny/thinking/thinking.js:68-75 – Map entries aren’t cleared when a thinking block completes (only deleted for empty text). Each streamed thinking block leaves a live entry holding DOM nodes, so a long session will leak memory. Delete the entry on done regardless of content. - pkg-r/inst/lib/shiny/thinking/thinking.js:39-45 – The retry loop (setTimeout) runs forever if a start arrives after the streaming message has disappeared (e.g., chat cleared or race on disconnect). Add a stop condition (max retries / bail when the chat container is gone) or drop the message when no streaming target exists. ``` --- pkg-r/inst/lib/shiny/thinking/thinking.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg-r/inst/lib/shiny/thinking/thinking.js b/pkg-r/inst/lib/shiny/thinking/thinking.js index 32419837..e3bc8fe1 100644 --- a/pkg-r/inst/lib/shiny/thinking/thinking.js +++ b/pkg-r/inst/lib/shiny/thinking/thinking.js @@ -35,11 +35,14 @@ function handleThinkingMessage(payload) { const { id, type, content: text } = payload; + const retries = payload._retries || 0; if (type === 'start') { const message = findStreamingMessage(); if (!message) { - setTimeout(() => handleThinkingMessage(payload), 50); + if (retries < 20) { + setTimeout(() => handleThinkingMessage({ ...payload, _retries: retries + 1 }), 50); + } return; } @@ -71,8 +74,8 @@ if (!parts.fullText.trim()) { parts.wrapper.remove(); - thinkingBlocks.delete(id); } + thinkingBlocks.delete(id); } }