Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion js/dist/shinychat.css

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions js/dist/shinychat.css.map

Large diffs are not rendered by default.

80 changes: 40 additions & 40 deletions js/dist/shinychat.js

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions js/dist/shinychat.js.map

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions js/src/chat/ChatApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ interface ChatAppProps {
initialMessages?: ChatMessageData[]
initialGreeting?: InitialGreeting
enableCancel?: boolean
footerEl?: Element
}

function makeInitialGreeting(
Expand Down Expand Up @@ -71,6 +72,7 @@ export function ChatApp({
initialMessages,
initialGreeting,
enableCancel,
footerEl,
}: ChatAppProps) {
const messages = initialMessages ?? []
const [state, dispatch] = useReducer(chatReducer, {
Expand Down Expand Up @@ -178,6 +180,7 @@ export function ChatApp({
cancelId={cancelId}
enableCancel={enableCancel}
cancelRequested={state.cancelRequested}
footerEl={footerEl}
/>
</ChatDispatchContext.Provider>
</ChatToolContext.Provider>
Expand Down
5 changes: 5 additions & 0 deletions js/src/chat/ChatContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { MessageErrorBoundary } from "./MessageErrorBoundary"
import { ChatInput, type ChatInputHandle } from "./ChatInput"
import { ScrollToBottomButton } from "./ScrollToBottomButton"
import { ExternalLinkDialogComponent } from "./ExternalLinkDialog"
import { RawDOM } from "./RawDOM"
import { ChatScrollContext, useChatDispatch } from "./context"
import type { ChatMessageData, GreetingData } from "./state"
import type { ChatTransport } from "../transport/types"
Expand All @@ -38,6 +39,7 @@ export interface ChatContainerProps {
cancelId?: string
enableCancel?: boolean
cancelRequested?: boolean
footerEl?: Element
}

export type ChatContainerHandle = ChatInputHandle
Expand All @@ -58,6 +60,7 @@ export const ChatContainer = forwardRef<
cancelId,
enableCancel,
cancelRequested,
footerEl,
},
ref,
) {
Expand Down Expand Up @@ -407,6 +410,8 @@ export const ChatContainer = forwardRef<
/>
</div>

{footerEl && <RawDOM source={footerEl} className="shiny-chat-footer" />}

{pendingUrl &&
createPortal(
<ExternalLinkDialogComponent
Expand Down
51 changes: 51 additions & 0 deletions js/src/chat/RawDOM.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useContext, useEffect, useRef } from "react"
import { ShinyLifecycleContext } from "./context"

// Complement to RawHTML: both use a ref div to opt out of React's DOM
// management and both manage Shiny bindings via ShinyLifecycleContext.
// RawHTML reconstructs DOM from an HTML string; RawDOM adopts existing
// DOM nodes by moving them from a source element. This preserves event
// listeners, widget state, and any other DOM state that a
// serialization round-trip through innerHTML would destroy.
//
// On setup: unbinds the source, moves children into the React-managed
// div, then rebinds in the new location. On cleanup: unbinds, moves
// children back to the source so they survive React unmount and can be
// re-adopted on remount (e.g. when the custom element is moved in the
// DOM, triggering disconnectedCallback → connectedCallback).
//
// Safety: the effect deps [source, shiny] are both referentially stable
// for the lifetime of the component — source is a DOM element stored as
// a private field on the custom element instance, and shiny is a
// window-global singleton provided via context — so the effect runs
// exactly once per mount.
export function RawDOM({
source,
className,
}: {
source: Element
className?: string
}) {
const ref = useRef<HTMLDivElement>(null)
const shiny = useContext(ShinyLifecycleContext)

useEffect(() => {
const el = ref.current
if (!el) return

if (shiny) shiny.unbindAll(source as HTMLElement)
while (source.firstChild) {
el.appendChild(source.firstChild)
}
if (shiny) shiny.bindAll(el)

return () => {
if (shiny) shiny.unbindAll(el)
while (el.firstChild) {
source.appendChild(el.firstChild)
}
}
}, [source, shiny])

return <div ref={ref} className={className} />
}
2 changes: 1 addition & 1 deletion js/src/chat/RawHTML.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useContext, useEffect, useRef, useState } from "react"
import { ShinyLifecycleContext } from "../chat/context"
import { ShinyLifecycleContext } from "./context"

// Uses a ref to opt out of React's DOM management, preventing React from
// resetting innerHTML and destroying content injected by Shiny bindings.
Expand Down
11 changes: 11 additions & 0 deletions js/src/chat/chat-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const transport = getShinyTransport()

const CHAT_INPUT_TAG = "shiny-chat-input"
const CHAT_MESSAGE_TAG = "shiny-chat-message"
const CHAT_FOOTER_TAG = "shiny-chat-footer"

function parseInitialMessages(container: HTMLElement): ChatMessageData[] {
const messageEls = container.querySelectorAll(CHAT_MESSAGE_TAG)
Expand Down Expand Up @@ -63,6 +64,7 @@ function parseInitialGreeting(

class ChatContainerElement extends HTMLElement {
private reactRoot: Root | null = null
private footerEl: Element | null = null

connectedCallback() {
if (this.reactRoot) return
Expand All @@ -80,6 +82,13 @@ class ChatContainerElement extends HTMLElement {

const initialMessages = parseInitialMessages(this)

if (!this.footerEl) {
this.footerEl = this.querySelector(CHAT_FOOTER_TAG)
// Detach from the DOM before React takes over this container.
// RawDOM later adopts the children, preserving their DOM state.
this.footerEl?.remove()
}
Comment thread
gadenbuie marked this conversation as resolved.

const initialGreeting = parseInitialGreeting(this)

// Unbind any Shiny inputs/outputs in the server-rendered content before
Expand All @@ -101,11 +110,13 @@ class ChatContainerElement extends HTMLElement {
initialMessages,
initialGreeting,
enableCancel,
footerEl: this.footerEl ?? undefined,
}),
)
}

disconnectedCallback() {
transport.unbindAll(this)
this.reactRoot?.unmount()
this.reactRoot = null
}
Expand Down
14 changes: 14 additions & 0 deletions js/src/chat/chat.scss
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ shiny-chat-container {
grid-template-columns: 1fr;
grid-template-rows: 1fr auto;
margin: 0 auto;
&:has(.shiny-chat-footer) {
grid-template-rows: 1fr auto auto;
}
gap: 0;
padding: var(--_chat-container-padding);
padding-bottom: 0; // Bottom padding is on input element
Expand Down Expand Up @@ -226,6 +229,17 @@ shiny-chat-container {
}
}

.shiny-chat-footer {
--shiny-chat-footer-font-size: 0.8125em;
--shiny-chat-footer-color: var(--bs-secondary-color, #6c757d);

font-size: var(--shiny-chat-footer-font-size);
color: var(--shiny-chat-footer-color);
text-align: center;
padding-block: 0.25rem;
padding-inline: var(--_chat-container-padding);
}

/*
Disable the page-level pulse when the chat input is disabled
(i.e., when a response is being generated and brought into the chat)
Expand Down
2 changes: 2 additions & 0 deletions pkg-py/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### New features

* Added `footer` parameter to `chat_ui()` and `Chat.ui()` for displaying arbitrary HTML content below the chat input. Useful for disclaimers, attribution, or interactive toolbars. Styled with sensible defaults and customizable via `--shiny-chat-footer-font-size` and `--shiny-chat-footer-color` CSS custom properties. (#224)

* Added `chat_greeting()` for creating welcome messages that appear when the chat is empty. Greetings can be set statically via `chat_ui(greeting=)` or dynamically with `Chat.set_greeting()`, and are dismissed when the user sends their first message. A new `{id}_greeting_requested` input fires when the chat is visible, empty, and has no greeting, enabling LLM-generated welcome messages. (#217)

* Tool result cards now render images and PDFs returned by chatlas tools. When a tool returns `ContentImageInline`, `ContentImageRemote`, or `ContentPDF`, the result is displayed as an inline image or a PDF filename badge. Mixed content lists (e.g., `[ContentText("summary"), content_image_file("plot.png")]`) are rendered with items interleaved in order. Standalone image and PDF content items in turn history are also rendered correctly. (#225)
Expand Down
22 changes: 22 additions & 0 deletions pkg-py/src/shinychat/_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -1851,6 +1851,7 @@ def ui(
fill: bool = True,
icon_assistant: HTML | Tag | TagList | None = None,
enable_cancel: bool = False,
footer: Optional[TagChild] = None,
**kwargs: TagAttrValue,
) -> Tag:
"""
Expand Down Expand Up @@ -1887,6 +1888,13 @@ def ui(
``input.<id>_cancel`` on the server and call ``ctrl.cancel()`` on a
chatlas ``StreamController`` to actually stop the stream. Defaults to
``False``.
footer
Optional HTML content to display below the chat input.
This can be any HTML content (tags, tag lists, or strings).
Useful for adding disclaimers, attribution, or other information.
The footer text is styled slightly smaller and lighter than body text
by default. Customize with CSS properties ``--shiny-chat-footer-font-size``
and ``--shiny-chat-footer-color`` on the chat container or footer element.
kwargs
Additional attributes for the chat container element.
"""
Expand All @@ -1901,6 +1909,7 @@ def ui(
fill=fill,
icon_assistant=icon_assistant,
enable_cancel=enable_cancel,
footer=footer,
**kwargs,
)

Expand Down Expand Up @@ -1970,6 +1979,7 @@ def chat_ui(
fill: bool = True,
icon_assistant: Optional[HTML | Tag | TagList] = None,
enable_cancel: bool = False,
footer: Optional[TagChild] = None,
**kwargs: TagAttrValue,
) -> Tag:
"""
Expand Down Expand Up @@ -2030,6 +2040,13 @@ def chat_ui(
``input.<id>_cancel`` on the server and call ``ctrl.cancel()`` on a
chatlas ``StreamController`` to actually stop the stream. Defaults to
``False``.
footer
Optional HTML content to display below the chat input.
This can be any HTML content (tags, tag lists, or strings).
Useful for adding disclaimers, attribution, or other information.
The footer text is styled slightly smaller and lighter than body text
by default. Customize with CSS properties ``--shiny-chat-footer-font-size``
and ``--shiny-chat-footer-color`` on the chat container or footer element.
kwargs
Additional attributes for the chat container element.
"""
Expand Down Expand Up @@ -2062,6 +2079,10 @@ def chat_ui(
)
)

footer_tag = None
if footer is not None:
footer_tag = Tag("shiny-chat-footer", footer)

greeting_attr: Optional[str] = None
greeting_deps: list[HTMLDependency] = []
if greeting is not None:
Expand Down Expand Up @@ -2091,6 +2112,7 @@ def chat_ui(
id=f"{id}_user_input",
placeholder=placeholder,
),
footer_tag,
shinychat_dependency(),
icon_deps,
{
Expand Down
2 changes: 1 addition & 1 deletion pkg-py/src/shinychat/www/GIT_VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
ae728f2114d14fc321b5b3addabdb1823d06965c
f97591b21e3af83d259dce77a8a7e6c63a0fcd59
2 changes: 1 addition & 1 deletion pkg-py/src/shinychat/www/shinychat.css

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions pkg-py/src/shinychat/www/shinychat.css.map

Large diffs are not rendered by default.

80 changes: 40 additions & 40 deletions pkg-py/src/shinychat/www/shinychat.js

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions pkg-py/src/shinychat/www/shinychat.js.map

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions pkg-r/NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

## New features and improvements

* Added `footer` parameter to `chat_ui()` for displaying arbitrary HTML content below the chat input. Useful for disclaimers, attribution, or interactive toolbars. Styled with sensible defaults and customizable via `--shiny-chat-footer-font-size` and `--shiny-chat-footer-color` CSS custom properties. (#224)

* Added `chat_greeting()` for creating welcome messages that appear when the chat is empty. Greetings can be set statically via `chat_ui(greeting=)` or dynamically from the server with `chat_set_greeting()`. They are automatically dismissed when the user sends their first message. A new `greeting_requested` input fires when the chat is visible, empty, and has no greeting, enabling LLM-generated welcome messages. `chat_mod_server(greeting=)` accepts a function for auto-generated greetings. (#217)

* Tool result cards now render images and PDFs returned by ellmer tools. When a tool returns `content_image_file()`, `content_image_url()`, or `content_pdf_file()`, the result is displayed as an inline image or a PDF filename badge. Mixed content lists (e.g., `list(ContentText("summary"), content_image_file("plot.png"))`) are rendered with items interleaved in order. (#225)
Expand Down
15 changes: 14 additions & 1 deletion pkg-r/R/chat.R
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,12 @@ chat_greeting <- function(
#' usage with `chat_ui()`, observe `input$<id>_cancel` to handle cancellation
#' (e.g., by calling `ctrl$cancel()` on an ellmer `stream_controller()`).
#' Defaults to `FALSE`.
#' @param footer Optional HTML content to display below the chat input.
#' This can be any HTML content (tags, tag lists, or character strings).
#' Useful for adding disclaimers, attribution, or other information.
#' The footer text is styled slightly smaller and lighter than body text
#' by default. Customize with CSS properties `--shiny-chat-footer-font-size`
#' and `--shiny-chat-footer-color` on the chat container or footer element.
#' @section Thinking display:
#'
#' When a model produces reasoning or "thinking" tokens, shinychat renders them
Expand Down Expand Up @@ -249,7 +255,8 @@ chat_ui <- function(
height = "auto",
fill = TRUE,
icon_assistant = NULL,
enable_cancel = FALSE
enable_cancel = FALSE,
footer = NULL
) {
attrs <- rlang::list2(...)
if (!all(nzchar(rlang::names2(attrs)))) {
Expand Down Expand Up @@ -285,6 +292,11 @@ chat_ui <- function(
)
})

footer_tag <- NULL
if (!is.null(footer)) {
footer_tag <- tag("shiny-chat-footer", list(footer))
}

# Process greeting -------------------------------------------------------
greeting_attr <- NULL
greeting_deps <- list()
Expand Down Expand Up @@ -365,6 +377,7 @@ chat_ui <- function(
"shiny-chat-input",
list(id = paste0(id, "_user_input"), placeholder = placeholder)
),
footer_tag,
shinychat_deps(),
htmltools::findDependencies(icon_assistant),
greeting_deps
Expand Down
2 changes: 1 addition & 1 deletion pkg-r/inst/lib/shiny/GIT_VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
ae728f2114d14fc321b5b3addabdb1823d06965c
895146c48c267da928857b0c76b35d8719bc464b
2 changes: 1 addition & 1 deletion pkg-r/inst/lib/shiny/shinychat.css

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions pkg-r/inst/lib/shiny/shinychat.css.map

Large diffs are not rendered by default.

80 changes: 40 additions & 40 deletions pkg-r/inst/lib/shiny/shinychat.js

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions pkg-r/inst/lib/shiny/shinychat.js.map

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion pkg-r/man/chat_ui.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading