Skip to content

feat(mod): Add set_client() and status to chat_mod_server() return value#227

Open
gadenbuie wants to merge 5 commits into
mainfrom
feat/mod-set-client
Open

feat(mod): Add set_client() and status to chat_mod_server() return value#227
gadenbuie wants to merge 5 commits into
mainfrom
feat/mod-set-client

Conversation

@gadenbuie
Copy link
Copy Markdown
Collaborator

Summary

  • Adds set_client(new_client, sync = TRUE) to the environment returned by chat_mod_server(), allowing the parent server to hot-swap the chat client (e.g. when a user switches models). When sync = TRUE, conversation turns, system prompt, and tools are copied from the old client to the new one. Mid-stream swaps are safely deferred until the stream completes.
  • Adds a status reactive ("idle" / "streaming") so the parent can observe the chat interaction state.
  • Changes client from a plain list element to an active binding that always reflects the current client, and changes the return type from list() to a locked environment.
  • Adds a restore_ui parameter to chat_restore() to support re-registering bookmark callbacks after a client swap without re-rendering the full conversation UI.

Verification

library(shiny)
library(shinychat)

ui <- page_fillable(
  selectInput("model", "Model", choices = c("openai", "anthropic")),
  textOutput("chat_status"),
  chat_mod_ui("chat")
)

server <- function(input, output, session) {
  chat <- chat_mod_server("chat", ellmer::chat_openai())

  output$chat_status <- renderText(paste("Status:", chat$status()))

  observeEvent(input$model, {
    new_client <- switch(input$model,
      openai = ellmer::chat_openai(),
      anthropic = ellmer::chat_anthropic()
    )
    chat$set_client(new_client)
  })
}

shinyApp(ui, server)

gadenbuie and others added 5 commits May 18, 2026 12:23
Allow replacing the chat client after module initialization. The new
`set_client(new_client, sync = TRUE)` function swaps the internal client
reference used by all module closures. When `sync` is TRUE, turns, system
prompt, and tools are copied from the old client to the new one.

If called while a stream is in progress, the swap is deferred until the
stream completes. Bookmarking is re-registered with the new client.

The return value is now a locked environment with an active binding for
`client`, so `chat$client` always reflects the current client.
Add `restore_ui` param to `chat_restore()` to control whether existing
client turns are rendered into the chat UI on registration. `do_swap()`
passes `restore_ui = FALSE` to avoid duplicating the already-displayed
conversation when re-registering bookmark callbacks.

Rename the `"update_last_turn"` observer to `"on_stream_complete"` to
reflect that it now also handles deferred client swaps.
@gadenbuie gadenbuie marked this pull request as ready for review May 18, 2026 17:33
@gadenbuie gadenbuie requested a review from cpsievert May 18, 2026 17:33
@cpsievert cpsievert requested a review from Copilot May 18, 2026 20:55
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds the ability to hot-swap the ellmer chat client in chat_mod_server() and exposes a status reactive describing whether a stream is in progress. The module's return value also changes from a list to a locked environment with client as an active binding, and chat_restore() gains a restore_ui flag so re-registration after a swap can skip rendering the existing turns.

Changes:

  • New set_client(new_client, sync = TRUE) and status exposed by chat_mod_server(), with mid-stream swaps deferred via a pending_swap reactive.
  • chat_mod_server() now returns a locked environment; client is exposed via makeActiveBinding() so callers always see the current client.
  • chat_restore() gains a restore_ui argument used by the swap path to re-register bookmark callbacks without re-rendering UI.

Reviewed changes

Copilot reviewed 2 out of 4 changed files in this pull request and generated 4 comments.

File Description
pkg-r/R/chat_app.R Implements set_client, swap_client, status reactive, deferred-swap observer, and switches return type to a locked environment with client as active binding.
pkg-r/R/chat_restore.R Adds restore_ui parameter to allow skipping initial UI rendering when re-registering after a client swap.
pkg-r/man/chat_app.Rd Regenerated docs reflecting the new environment return value, status, and set_client().
pkg-r/man/chat_restore.Rd Regenerated docs for the new restore_ui parameter.
Files not reviewed (2)
  • pkg-r/man/chat_app.Rd: Language not supported
  • pkg-r/man/chat_restore.Rd: Language not supported
Comments suppressed due to low confidence (1)

pkg-r/R/chat_app.R:267

  • After swap_client() runs (either inline from set_client() or via the deferred path in this observer), it sets client <<- new_client and changes pending_swap() to NULL. Because this observer takes a reactive dependency on pending_swap(), it will re-execute. At that point, append_stream_task$status() is still "success", so last_turn(client$last_turn()) runs again — this time against the new client. When sync = FALSE, the new client has no turns, so last_turn will be silently overwritten with NULL, losing the last assistant turn that was just published to consumers. Consider gating the last_turn update so it only fires when the task status actually transitions to success (e.g. track the previously-seen status), or only run the swap branch when status is not "success" / when there is no fresh result to publish.
    shiny::observe(label = "on_stream_complete", {
      status <- append_stream_task$status()
      swap <- pending_swap()

      if (status == "success") {
        last_turn(client$last_turn())
      }

      if (!is.null(swap) && status != "running") {
        pending_swap(NULL)
        swap_client(swap$client, swap$sync)
      }
    })

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread pkg-r/R/chat_app.R
Comment on lines +230 to +239
set_client <- function(new_client, sync = TRUE) {
check_ellmer_chat(new_client)

if (append_stream_task$status() == "running") {
pending_swap(list(client = new_client, sync = sync))
return(invisible())
}

swap_client(new_client, sync)
}
Comment thread pkg-r/R/chat_app.R
Comment on lines +212 to +228
swap_client <- function(new_client, sync) {
if (sync) {
new_client$set_turns(client$get_turns())
new_client$set_system_prompt(client$get_system_prompt())
new_client$set_tools(client$get_tools())
}
client <<- new_client
chat_restore(
"chat",
client,
session = session,
bookmark_on_input = bookmark_on_input,
bookmark_on_response = bookmark_on_response,
restore_ui = FALSE
)
invisible()
}
Comment thread pkg-r/R/chat_app.R
ret$last_turn <- shiny::reactive(last_turn(), label = "mod_last_turn")
ret$last_input <- shiny::reactive(last_input(), label = "mod_last_input")
ret$status <- shiny::reactive(label = "mod_status", {
if (append_stream_task$status() == "running") "streaming" else "idle"
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered this during the design phase and decided that we only need idle and streaming for now

Comment thread pkg-r/R/chat_app.R
Comment on lines +219 to +226
chat_restore(
"chat",
client,
session = session,
bookmark_on_input = bookmark_on_input,
bookmark_on_response = bookmark_on_response,
restore_ui = FALSE
)
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.

2 participants