Skip to content

feat: add footer argument to chat_ui()#224

Open
gadenbuie wants to merge 8 commits into
mainfrom
feat/footer
Open

feat: add footer argument to chat_ui()#224
gadenbuie wants to merge 8 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.

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