Skip to content

feat: add footer argument to chat_ui()#224

Merged
gadenbuie merged 18 commits into
mainfrom
feat/footer
May 26, 2026
Merged

feat: add footer argument to chat_ui()#224
gadenbuie merged 18 commits into
mainfrom
feat/footer

Conversation

@gadenbuie
Copy link
Copy Markdown
Collaborator

@gadenbuie gadenbuie commented May 15, 2026

Summary

Adds a new footer parameter to chat_ui() (Python and R) that renders arbitrary HTML content below the chat input. Common use cases include disclaimers, attribution text, or interactive toolbars (e.g. model-selection dropdowns, action buttons).

  • The footer is extracted from server-rendered HTML and passed into the React component, so Shiny inputs inside the footer (buttons, selects, etc.) work correctly.
  • Styled with sensible defaults (smaller, muted text, centered) and customizable via --shiny-chat-footer-font-size and --shiny-chat-footer-color CSS custom properties.
  • The chat container grid layout expands from 1fr auto to 1fr auto auto when a footer is present.

Examples

Simple text disclaimer:

chat_ui(
  "chat",
  footer = "AI responses may be inaccurate. Please verify important information."
)
image

Toolbar with model selector and action buttons:

chat_ui(
  "chat",
  footer = toolbar(
    toolbar_input_select(
      "model_select",
      label = "Model",
      choices = c("GPT-4o", "Claude Sonnet", "Gemini Pro"),
      icon = shiny::icon("robot")
    ),
    toolbar_spacer(),
    toolbar_input_button(
      "copy_btn",
      label = "Copy chat",
      icon = shiny::icon("copy")
    ),
    toolbar_input_button(
      "clear_btn",
      label = "Clear chat",
      icon = shiny::icon("trash")
    ),
    align = "left",
    width = "100%"
  )
)
image

Verification

Full example app (Python)
import asyncio

from faicons import icon_svg
from shiny import App, reactive, render, ui

from shinychat import chat_ui, Chat

FOOTER_VARIANTS = {
    "none": "1. No footer (default)",
    "text": "2. Simple text disclaimer",
    "html": "3. HTML with a link",
    "toolbar": "4. Toolbar footer",
    "styled": "5. Custom styled footer",
}


def make_footer(variant: str):
    if variant == "none":
        return None
    if variant == "text":
        return "AI responses may be inaccurate. Please verify important information."
    if variant == "html":
        return ui.div(
            "Powered by ",
            ui.a("ExampleAI", href="https://example.com", target="_blank"),
        )
    if variant == "toolbar":
        return ui.toolbar(
            ui.toolbar_input_select(
                "model_select",
                label="Model",
                choices=["GPT-4o", "Claude Sonnet", "Gemini Pro"],
                icon=icon_svg("robot"),
            ),
            ui.toolbar_spacer(),
            ui.toolbar_input_button(
                "copy_btn",
                label="Copy chat",
                icon=icon_svg("copy"),
            ),
            ui.toolbar_input_button(
                "clear_btn",
                label="Clear chat",
                icon=icon_svg("trash"),
            ),
            align="left",
            width="100%",
        )
    if variant == "styled":
        return ui.div(
            icon_svg("triangle-exclamation"),
            " Experimental feature — use at your own risk",
            style=(
                "--shiny-chat-footer-font-size: 0.7em;"
                " --shiny-chat-footer-color: #e74c3c;"
            ),
        )
    return None


app_ui = ui.page_sidebar(
    ui.sidebar(
        ui.input_radio_buttons(
            "footer_variant",
            label="Footer variant",
            choices=FOOTER_VARIANTS,
            selected="none",
        ),
        ui.hr(),
        ui.input_action_button(
            "clear_chat",
            "Clear chat",
            class_="btn-outline-secondary w-100",
        ),
        width=280,
        open="always",
    ),
    ui.output_ui("chat_panel", fillable=True),
    title="shinychat — footer demo",
    fillable=True,
)


def server(input, output, session):
    chat = Chat(id="chat")

    @output
    @render.ui
    def chat_panel():
        footer = make_footer(input.footer_variant())
        return chat_ui(
            "chat",
            footer=footer,
            placeholder="Type a message...",
            fill=True,
        )

    @chat.on_user_submit
    async def _():
        user_msg = chat.user_input()
        reply = f"**You said:** {user_msg}"

        async def stream():
            for word in reply.split(" "):
                yield word + " "
                await asyncio.sleep(0.04)

        await chat.append_message_stream(stream())

    @reactive.effect
    @reactive.event(input.clear_btn)
    async def _on_toolbar_clear():
        await chat.clear_messages()

    @reactive.effect
    @reactive.event(input.clear_chat)
    async def _on_sidebar_clear():
        await chat.clear_messages()


app = App(app_ui, server)
Full example app (R)
library(shiny)
library(bslib)
library(coro)
library(shinychat)

make_echo_stream <- generator(function(user_input) {
  reply <- paste0("**You said:** ", user_input)
  words <- strsplit(reply, " ")[[1]]
  for (i in seq_along(words)) {
    yield(if (i == 1) words[[i]] else paste0(" ", words[[i]]))
    Sys.sleep(0.04)
  }
})

footer_variants <- c(
  "1. No footer (default)" = "none",
  "2. Simple text disclaimer" = "text",
  "3. HTML with a link" = "html",
  "4. Toolbar footer" = "toolbar",
  "5. Custom styled footer" = "styled"
)

make_footer <- function(variant) {
  switch(
    variant %||% "none",
    none = NULL,
    text = "AI responses may be inaccurate. Please verify important information.",
    html = div(
      "Powered by ",
      tags$a(href = "https://example.com", "ExampleAI", target = "_blank")
    ),
    toolbar = toolbar(
      align = "left",
      width = "100%",
      toolbar_input_select(
        "model_select",
        label = "Model",
        choices = c("GPT-4o", "Claude Sonnet", "Gemini Pro"),
        icon = shiny::icon("robot")
      ),
      toolbar_spacer(),
      toolbar_input_button(
        "copy_btn",
        label = "Copy chat",
        icon = shiny::icon("copy")
      ),
      toolbar_input_button(
        "clear_btn",
        label = "Clear chat",
        icon = shiny::icon("trash")
      )
    ),
    styled = div(
      style = "--shiny-chat-footer-font-size: 0.7em; --shiny-chat-footer-color: #e74c3c;",
      icon("triangle-exclamation"),
      " Experimental feature — use at your own risk"
    )
  )
}

ui <- page_sidebar(
  title = "shinychat — footer demo",
  fillable = TRUE,
  sidebar = sidebar(
    width = 280,
    open = "always",
    radioButtons(
      "footer_variant",
      label = "Footer variant",
      choices = footer_variants,
      selected = "none"
    ),
    hr(),
    actionButton(
      "clear_chat",
      "Clear chat",
      class = "btn-outline-secondary w-100"
    )
  ),
  uiOutput("chat_panel", fill = TRUE)
)

server <- function(input, output, session) {
  output$chat_panel <- renderUI({
    footer <- make_footer(input$footer_variant)
    chat_ui(
      "chat",
      footer = footer,
      placeholder = "Type a message...",
      fill = TRUE
    )
  })

  observeEvent(input$chat_user_input, {
    stream <- make_echo_stream(input$chat_user_input)
    chat_append("chat", stream)
  })

  observeEvent(input$clear_btn, {
    chat_clear("chat")
  })
}

shinyApp(ui, server)

gadenbuie and others added 4 commits May 15, 2026 12:53
Allows users to place arbitrary HTML content below the chat input,
rendered as an HTML island via the RawHTML component (with Shiny
binding support). Footer text is styled slightly smaller and lighter
by default, customizable via `--shiny-chat-footer-font-size` and
`--shiny-chat-footer-color` CSS properties.
@gadenbuie gadenbuie marked this pull request as ready for review May 15, 2026 17:03
@gadenbuie gadenbuie requested a review from cpsievert May 15, 2026 17:03
Comment thread pkg-py/src/shinychat/_chat.py Outdated
Comment on lines +1894 to +1903
footer_tag = None
footer_deps = None
if footer is not None:
if isinstance(footer, str):
footer_tag = Tag("shiny-chat-footer", HTML(footer))
elif isinstance(footer, (Tag, TagList)):
footer_tag = Tag("shiny-chat-footer", HTML(str(footer)))
footer_deps = footer.get_dependencies()
else:
footer_tag = Tag("shiny-chat-footer", HTML(str(footer)))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What's the reasoning for needing to "pre-render"? Also, I think you could drop the first if condition here.

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.

Good call on both points.

  1. Simplified the Python branching — collapsed the three-way if/elif/else down to two branches (one to extract deps from Tag/TagList, then a single Tag("shiny-chat-footer", HTML(str(footer))) for all cases).

  2. Moved the rendering logic to JS — instead of extracting innerHTML as a string and re-rendering it via RawHTML in React, the JS now preserves the server-rendered <shiny-chat-footer> DOM element and moves its child nodes directly into the React tree via a ref. This means:

    • No serialize→deserialize roundtrip for the footer HTML
    • Shiny bindings on footer elements aren't destroyed and rebound
    • R and Python only need to emit the <shiny-chat-footer> wrapper (minimal, identical contract)

gadenbuie added 2 commits May 15, 2026 15:48
…eservation

Instead of extracting innerHTML from the server-rendered <shiny-chat-footer>
element and re-rendering it via RawHTML in React, preserve the DOM element
and move its child nodes directly into the React tree via a ref. This avoids
a serialize/deserialize roundtrip and keeps Shiny bindings intact.

Also simplifies the Python footer code from three branches to two.
Comment thread pkg-py/src/shinychat/_chat.py Outdated
@cpsievert
Copy link
Copy Markdown
Collaborator

One thing I noticed while reading through the JS: the appendChild loop + manual bindAll/unbindAll in ChatContainer is a pattern we don't use anywhere else. We already have RawHTML which does exactly this — sets innerHTML, calls bindAll on mount, unbindAll on cleanup — and it's what every chat message island and ToolCard footer uses for server-rendered HTML.

If we captured footerEl.innerHTML as a string in chat-entry.ts instead of passing the Element itself, the React side could just be:

{footerHtml && (
  <RawHTML html={footerHtml} displayContents={false} className="shiny-chat-footer" />
)}

That would let us drop the useEffect, footerRef, and the ShinyLifecycleContext import from ChatContainer — it shouldn't need to know about Shiny binding at all. It also sidesteps a subtle fragility where the appendChild loop is a one-shot transfer of DOM nodes, so if React ever re-ran that effect, the footer would silently go empty (the nodes already got moved out and the cleanup doesn't move them back).

Mostly a consistency argument — the island pattern already handles this exact scenario well, and reusing it keeps one fewer way of doing the same thing.

@gadenbuie
Copy link
Copy Markdown
Collaborator Author

Mostly a consistency argument — the island pattern already handles this exact scenario well, and reusing it keeps one fewer way of doing the same thing.

This was the pattern I was using until your comment about pre-rendering #224 (comment)

@gadenbuie
Copy link
Copy Markdown
Collaborator Author

If we captured footerEl.innerHTML as a string in chat-entry.ts instead of passing the Element itself, the React side could just be:

One thing that I liked about moving away from this approach was that if we're capturing footerEl.innerHTML we have to also unbind Shiny inputs and outputs. So this approach ends up causing everything in footer to bind, unbind and then rebind when the chat UI is loaded.

@cpsievert
Copy link
Copy Markdown
Collaborator

Good points — I see now that the remove() before unbindAll() is intentional, keeping the footer's bindings intact through the React mount. That's a cleaner lifecycle than the innerHTML path, which would add an unbind/rebind cycle at load time.

I'll also admit that when I left the earlier comment about pre-rendering, I hadn't fully read through the TypeScript changes yet, so I didn't have the full picture of what you were optimizing for.

That said, I think I'd still err toward reusing RawHTML here. The DOM adoption approach seems prone to subtle bugs: the appendChild loop is a one-shot transfer, so if React ever re-ran that effect the footer would silently empty out; and ChatContainer takes on bindAll/unbindAll responsibility that's otherwise contained in RawHTML. The extra bind cycle at page load feels like a worthwhile trade for avoiding those sharp edges.

If we do want the cleaner lifecycle, I think it'd be worth extracting the pattern into a proper primitive (something like RawDOM — analogous to RawHTML but accepting an Element instead of a string). That way the adoption logic, binding lifecycle, and re-run safety live in one place rather than inline in ChatContainer. But building and maintaining a second primitive alongside RawHTML for a one-time bind cycle at page load may not be worth it.

Either way, I don't feel strongly — happy to leave it to your judgment.

gadenbuie added 8 commits May 22, 2026 11:40
Move the inline DOM adoption logic from ChatContainer into a dedicated
RawDOM component. RawDOM moves pre-bound DOM nodes into the React tree
without triggering a Shiny rebind cycle, and returns them to the source
element on cleanup so they survive disconnect/reconnect (DOM moves).

Also stores footerEl as an instance field on the custom element so the
reference persists across disconnectedCallback/connectedCallback cycles.
# Conflicts:
#	js/dist/shinychat.css
#	js/dist/shinychat.css.map
#	js/dist/shinychat.js
#	js/dist/shinychat.js.map
#	js/src/chat/chat-entry.ts
#	pkg-py/CHANGELOG.md
#	pkg-py/src/shinychat/_chat.py
#	pkg-py/src/shinychat/www/GIT_VERSION
#	pkg-py/src/shinychat/www/shinychat.css
#	pkg-py/src/shinychat/www/shinychat.css.map
#	pkg-py/src/shinychat/www/shinychat.js
#	pkg-py/src/shinychat/www/shinychat.js.map
#	pkg-r/NEWS.md
#	pkg-r/R/chat.R
#	pkg-r/inst/lib/shiny/GIT_VERSION
#	pkg-r/inst/lib/shiny/shinychat.css
#	pkg-r/inst/lib/shiny/shinychat.css.map
#	pkg-r/inst/lib/shiny/shinychat.js
#	pkg-r/inst/lib/shiny/shinychat.js.map
Unbind before moving nodes into the React tree, rebind after. Reverse
on cleanup. Ensures correct binding state regardless of how the chat
container was rendered (static page load vs renderUI).
@gadenbuie
Copy link
Copy Markdown
Collaborator Author

Thanks for the thoughtful review, Carson. I ended up going with a variation of both suggestions — a RawDOM component (the primitive you suggested) that manages Shiny bindings explicitly (the RawHTML reuse you preferred).

What changed

Instead of the inline DOM adoption in ChatContainer, there's now a RawDOM component — a complement to RawHTML. Both use a ref div to opt out of React's DOM management and both manage Shiny bindings via ShinyLifecycleContext. The difference is the content source:

  • RawHTML: accepts an HTML string, reconstructs DOM via innerHTML, binds from scratch
  • RawDOM: accepts a DOM Element, moves original nodes via appendChild, unbinds before moving and rebinds after

RawDOM preserves event listeners, widget state, and other DOM state that a serialization round-trip through innerHTML would destroy.

Binding lifecycle

I initially tried the zero-rebind approach (nodes arrive pre-bound, skip bindAll), but the binding state at adoption time varies depending on how the chat container is rendered (static page load vs renderUI()). An explicit unbind/rebind around each move is consistent regardless of rendering context:

Setup: unbindAll(source) → move children → bindAll(target)
Cleanup: unbindAll(target) → move children back to source

The cleanup round-trip means nodes survive React unmount and can be re-adopted when the custom element is moved in the DOM (disconnect → reconnect).

Why the one-shot appendChild is safe

The effect deps [source, shiny] are both referentially stable:

  • source is a DOM element stored as a private field on the custom element instance, set once in connectedCallback
  • shiny is the ShinyLifecycleContext value — the transport window-global singleton

Neither changes identity across re-renders, so the effect runs exactly once per mount. We enumerated all the ways this assumption could break — HMR, DOM moves, concurrent rendering, StrictMode, conditional rendering, context value replacement — and are confident it holds. The cleanup/setup cycle is idempotent, so even under StrictMode's double-fire the behavior is correct. Happy to walk through the detailed reasoning if you'd like.

@gadenbuie gadenbuie requested a review from cpsievert May 22, 2026 18:19
Comment thread js/src/chat/chat-entry.ts
@gadenbuie gadenbuie merged commit ff8e27c into main May 26, 2026
18 checks passed
@gadenbuie gadenbuie deleted the feat/footer branch May 26, 2026 15:49
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