Skip to content
1 change: 1 addition & 0 deletions pkg-py/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Improvements

* Chat greetings now use shinychat's greeting API (requires shinychat >= 0.4.0). A provided `greeting` renders instantly when the app loads, and when no `greeting` is given one is generated on demand without being added to the conversation history. Generated greetings are now preserved across bookmark/restore. (#249)
* The query tool result card now starts collapsed by default. Users can still expand it to see the SQL query and results. Set `QUERYCHAT_TOOL_DETAILS=expanded` to restore the previous behavior. (#239)

## [0.6.1] - 2026-05-26
Expand Down
4 changes: 2 additions & 2 deletions pkg-py/src/querychat/_shiny.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ def ui(self, *, id: Optional[str] = None, **kwargs):
A UI component.

"""
return mod_ui(id or self.id, preload_viz=has_viz_tool(self.tools), **kwargs)
return mod_ui(id or self.id, preload_viz=has_viz_tool(self.tools), greeting=self.greeting, **kwargs)

def server(
self,
Expand Down Expand Up @@ -819,7 +819,7 @@ def ui(self, *, id: Optional[str] = None, **kwargs):
A UI component.

"""
return mod_ui(id or self.id, preload_viz=has_viz_tool(self.tools), **kwargs)
return mod_ui(id or self.id, preload_viz=has_viz_tool(self.tools), greeting=self.greeting, **kwargs)

def df(self) -> IntoFrameT:
"""
Expand Down
69 changes: 50 additions & 19 deletions pkg-py/src/querychat/_shiny_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,15 @@ def __getattr__(self, _name: str):


@module.ui
def mod_ui(*, preload_viz: bool = False, **kwargs):
def mod_ui(*, preload_viz: bool = False, greeting: str | None = None, **kwargs):
css_path = Path(__file__).parent / "static" / "css" / "styles.css"
js_path = Path(__file__).parent / "static" / "js" / "querychat.js"

kwargs.setdefault("enable_cancel", True)
if greeting:
kwargs.setdefault(
"greeting", shinychat.chat_greeting(greeting, dismissible=False)
)
tag = shinychat.chat_ui(CHAT_ID, **kwargs)
tag.add_class("querychat")

Expand Down Expand Up @@ -128,7 +132,13 @@ def mod_server(
# Reactive values to store state
sql = ReactiveStringOrNone(None)
title = ReactiveStringOrNone(None)
has_greeted = reactive.value[bool](False) # noqa: FBT003
# Holds a generated greeting so it can be saved and restored on bookmark.
# Static greetings live in the UI (chat_ui(greeting=)) and persist already.
# Workaround for posit-dev/shinychat#253: shinychat does not bookmark
# greetings or expose their state. If that issue is fixed, this value, the
# get_last_turn() capture below, and the greeting handling in
# on_bookmark/on_restore can be dropped (and the shinychat minimum bumped).
current_greeting = ReactiveStringOrNone(None)

if not callable(client):
raise TypeError("mod_server() requires a callable client factory.")
Expand Down Expand Up @@ -199,33 +209,47 @@ def filtered_df():
# Handle user input
@chat_ui.on_user_submit
async def _(user_input: str):
stream = await chat.stream_async(user_input, echo="none", content="all", controller=ctrl)
stream = await chat.stream_async(
user_input, echo="none", content="all", controller=ctrl
)
await chat_ui.append_message_stream(stream)

@reactive.effect
@reactive.event(input[f"{CHAT_ID}_cancel"])
def _handle_cancel():
ctrl.cancel()

@reactive.effect
async def greet_on_startup():
if has_greeted():
return

if greeting:
await chat_ui.append_message(greeting)
elif greeting is None:
if greeting is None:

@reactive.effect
@reactive.event(input[f"{CHAT_ID}_greeting_requested"])
async def _handle_greeting_requested():
# Re-display a restored greeting rather than generating a new one.
# On empty-chat restore both this and on_restore set the greeting
# (harmless, identical content); on non-empty restore this never
# fires, so on_restore is the only path that re-displays.
existing = current_greeting.get()
if existing is not None:
await chat_ui.set_greeting(
shinychat.chat_greeting(existing, dismissible=False)
)
return
warnings.warn(
"No greeting provided to `QueryChat()`. Using the LLM `client` to generate one now. "
"For faster startup, lower cost, and determinism, consider providing a greeting "
"to `QueryChat()` and `.generate_greeting()` to generate one beforehand.",
GreetWarning,
stacklevel=2,
)
stream = await chat.stream_async(GREETING_PROMPT, echo="none")
await chat_ui.append_message_stream(stream)

has_greeted.set(True)
greeting_client = client(tools=None)
stream = await greeting_client.stream_async(GREETING_PROMPT, echo="none")
await chat_ui.set_greeting(
shinychat.chat_greeting(stream, dismissible=False)
)
# Capture the generated greeting so it can be bookmarked and restored.
last_turn = greeting_client.get_last_turn(role="assistant")
if last_turn is not None:
current_greeting.set(last_turn.text)

# Handle update button clicks
@reactive.effect
Expand All @@ -252,19 +276,26 @@ def _on_bookmark(x: BookmarkState) -> None:
vals = x.values
vals["querychat_sql"] = sql.get()
vals["querychat_title"] = title.get()
vals["querychat_has_greeted"] = has_greeted.get()
greeting_val = current_greeting.get()
if greeting_val is not None:
vals["querychat_greeting"] = greeting_val
if viz_widgets:
vals["querychat_viz_widgets"] = viz_widgets

@session.bookmark.on_restore
def _on_restore(x: RestoreState) -> None:
async def _on_restore(x: RestoreState) -> None:
vals = x.values
if "querychat_sql" in vals:
sql.set(vals["querychat_sql"])
if "querychat_title" in vals:
title.set(vals["querychat_title"])
if "querychat_has_greeted" in vals:
has_greeted.set(vals["querychat_has_greeted"])
if "querychat_greeting" in vals:
current_greeting.set(vals["querychat_greeting"])
await chat_ui.set_greeting(
shinychat.chat_greeting(
vals["querychat_greeting"], dismissible=False
)
)
if "querychat_viz_widgets" in vals:
restored = restore_viz_widgets(
data_source, vals["querychat_viz_widgets"]
Expand Down
6 changes: 4 additions & 2 deletions pkg-py/tests/playwright/test_01_hello_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ def test_app_loads_successfully(self) -> None:

def test_welcome_message_appears(self) -> None:
"""INIT-02: Chat shows LLM greeting."""
expect(self.chat.loc_messages).to_contain_text("Hello", timeout=30000)
greeting = self.chat.loc.locator(".shiny-chat-greeting")
expect(greeting).to_contain_text("Hello", timeout=30000)

def test_default_sql_query_shown(self) -> None:
"""INIT-03: SQL panel shows default query."""
Expand All @@ -66,7 +67,8 @@ def test_chat_input_visible(self) -> None:

def test_suggestion_links_present(self) -> None:
"""INIT-07: Suggestions are visible in greeting."""
expect(self.chat.loc_messages).to_contain_text(
greeting = self.chat.loc.locator(".shiny-chat-greeting")
expect(greeting).to_contain_text(
re.compile(r"survived|class|age", re.IGNORECASE), timeout=30000
)

Expand Down
3 changes: 2 additions & 1 deletion pkg-py/tests/playwright/test_02_prompt_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ def test_app_loads_successfully(self) -> None:
def test_custom_greeting_appears(self) -> None:
"""INIT-02: Custom greeting from greeting.md is shown."""
# The custom greeting should contain content from greeting.md
expect(self.chat.loc_messages).to_contain_text("Hello", timeout=30000)
greeting = self.chat.loc.locator(".shiny-chat-greeting")
expect(greeting).to_contain_text("Hello", timeout=30000)

def test_default_sql_query_shown(self) -> None:
"""INIT-03: SQL panel shows default query."""
Expand Down
6 changes: 4 additions & 2 deletions pkg-py/tests/playwright/test_03_sidebar_apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ def test_page_title(self) -> None:

def test_welcome_message_appears(self) -> None:
"""Chat shows LLM greeting."""
expect(self.chat.loc_messages).to_contain_text("Hello", timeout=30000)
greeting = self.chat.loc.locator(".shiny-chat-greeting")
expect(greeting).to_contain_text("Hello", timeout=30000)

def test_card_header_initial(self) -> None:
"""Card header shows 'Titanic Dataset' initially."""
Expand Down Expand Up @@ -127,7 +128,8 @@ def setup(self, page: Page, app_03_core: str, chat_03_core: ChatController) -> N

def test_welcome_message_appears(self) -> None:
"""Chat shows LLM greeting."""
expect(self.chat.loc_messages).to_contain_text("Hello", timeout=30000)
greeting = self.chat.loc.locator(".shiny-chat-greeting")
expect(greeting).to_contain_text("Hello", timeout=30000)

def test_card_header_initial(self) -> None:
"""Card header shows 'Titanic Dataset' initially."""
Expand Down
5 changes: 2 additions & 3 deletions pkg-py/tests/playwright/test_10_viz_inline.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,8 @@ def setup(
"""Navigate to the viz app before each test."""
page.goto(app_10_viz)
page.wait_for_selector("shiny-chat-container", timeout=30000)
expect(chat_10_viz.loc_latest_message).to_contain_text(
"Welcome", timeout=30000
)
greeting = chat_10_viz.loc.locator(".shiny-chat-greeting")
expect(greeting).to_contain_text("Welcome", timeout=30000)
self.page = page
self.chat = chat_10_viz

Expand Down
3 changes: 2 additions & 1 deletion pkg-py/tests/playwright/test_11_viz_footer.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ def _send_viz_prompt(
"""Navigate to the viz app and trigger a visualization before each test."""
page.goto(app_10_viz)
page.wait_for_selector("shiny-chat-container", timeout=30_000)
expect(chat_10_viz.loc_latest_message).to_contain_text("Welcome", timeout=30_000)
greeting = chat_10_viz.loc.locator(".shiny-chat-greeting")
expect(greeting).to_contain_text("Welcome", timeout=30_000)

chat_10_viz.set_user_input(VIZ_PROMPT)
chat_10_viz.send_user_input(method="click")
Expand Down
5 changes: 2 additions & 3 deletions pkg-py/tests/playwright/test_12_viz_bookmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,8 @@ def setup(

page.goto(app_viz_bookmark)
page.wait_for_selector("shiny-chat-container", timeout=30_000)
expect(chat_viz_bookmark.loc_latest_message).to_contain_text(
"Welcome", timeout=30_000
)
greeting = chat_viz_bookmark.loc.locator(".shiny-chat-greeting")
expect(greeting).to_contain_text("Welcome", timeout=30_000)

self.pre_viz_url = page.url

Expand Down
4 changes: 4 additions & 0 deletions pkg-r/NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# querychat (development version)

## Improvements

* Chat greetings now use shinychat's greeting API (requires shinychat >= 0.4.0). A provided `greeting` renders instantly when the app loads, and when no `greeting` is given one is generated on demand without being added to the conversation history. Generated greetings are now preserved across bookmark/restore. (#249)

# querychat 0.3.0

## New features
Expand Down
2 changes: 1 addition & 1 deletion pkg-r/R/QueryChat.R
Original file line number Diff line number Diff line change
Expand Up @@ -680,7 +680,7 @@ QueryChat <- R6::R6Class(
# implicitly by UI functions like shinychat.chat_ui().
id <- id %||% namespaced_id(self$id)

mod_ui(id, ...)
mod_ui(id, ..., greeting = self$greeting)
},

#' @description
Expand Down
90 changes: 63 additions & 27 deletions pkg-r/R/querychat_module.R
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
# Main module UI function
mod_ui <- function(id, ..., enable_cancel = TRUE) {
mod_ui <- function(id, ..., greeting = NULL, enable_cancel = TRUE) {
ns <- shiny::NS(id)

if (!is.null(greeting) && any(nzchar(greeting))) {
greeting <- shinychat::chat_greeting(greeting, dismissible = FALSE)
} else {
greeting <- NULL
}

htmltools::tagList(
htmltools::htmlDependency(
"querychat",
Expand All @@ -15,6 +22,7 @@ mod_ui <- function(id, ..., enable_cancel = TRUE) {
height = "100%",
class = "querychat",
enable_cancel = enable_cancel,
greeting = greeting,
...
)
)
Expand All @@ -32,7 +40,13 @@ mod_server <- function(
shiny::moduleServer(id, function(input, output, session) {
current_title <- shiny::reactiveVal(NULL, label = "current_title")
current_query <- shiny::reactiveVal(NULL, label = "current_query")
has_greeted <- shiny::reactiveVal(FALSE, label = "has_greeted")
# Holds a generated greeting so it can be saved and restored on bookmark.
# Static greetings live in the UI (chat_ui(greeting=)) and persist already.
# Workaround for posit-dev/shinychat#253: shinychat does not bookmark
# greetings or expose their state. If that issue is fixed, this reactiveVal,
# the last_turn() capture below, and the greeting handling in
# onBookmark/onRestore can be dropped (and the shinychat minimum bumped).
current_greeting <- shiny::reactiveVal(NULL, label = "current_greeting")
filtered_df <- shiny::reactive(label = "filtered_df", {
data_source$execute_query(query = current_query())
})
Expand Down Expand Up @@ -83,28 +97,40 @@ mod_server <- function(
session = session
)

# Prepopulate the chat UI with a welcome message that appears to be from the
# chat model (but is actually hard-coded). This is just for the user, not for
# the chat model to see.
shiny::observe(label = "greet_on_startup", {
if (has_greeted()) {
return()
}

greeting_content <- if (!is.null(greeting) && any(nzchar(greeting))) {
greeting
} else {
cli::cli_warn(c(
"No {.arg greeting} provided to {.fn QueryChat}. Using the LLM {.arg client} to generate one now.",
"i" = "For faster startup, lower cost, and determinism, consider providing a {.arg greeting} to {.fn QueryChat}.",
"i" = "You can use your {.help querychat::QueryChat} object's {.fn $generate_greeting} method to generate a greeting."
))
chat$stream_async(GREETING_PROMPT)
}

shinychat::chat_append("chat", greeting_content)
has_greeted(TRUE)
})
if (is.null(greeting)) {
shiny::observeEvent(
input$chat_greeting_requested,
label = "on_greeting_requested",
{
# Re-display a restored greeting rather than generating a new one.
# On empty-chat restore both this and onRestore set the greeting
# (harmless, identical content); on non-empty restore this never fires,
# so onRestore is the only path that re-displays.
if (!is.null(current_greeting())) {
shinychat::chat_set_greeting(
"chat",
shinychat::chat_greeting(current_greeting(), dismissible = FALSE)
)
return()
}
cli::cli_warn(c(
"No {.arg greeting} provided to {.fn QueryChat}. Using the LLM {.arg client} to generate one now.",
"i" = "For faster startup, lower cost, and determinism, consider providing a {.arg greeting} to {.fn QueryChat}.",
"i" = "You can use your {.help querychat::QueryChat} object's {.fn $generate_greeting} method to generate a greeting."
))
greeting_client <- client(tools = NULL)
stream <- greeting_client$stream_async(GREETING_PROMPT)
p <- shinychat::chat_set_greeting(
"chat",
shinychat::chat_greeting(stream, dismissible = FALSE)
)
# Capture the generated greeting so it can be bookmarked and restored.
promises::then(p, function(value) {
current_greeting(greeting_client$last_turn()@text)
})
}
)
}

ctrl <- ellmer::stream_controller()

Expand Down Expand Up @@ -142,7 +168,9 @@ mod_server <- function(
shiny::onBookmark(function(state) {
state$values$querychat_sql <- current_query()
state$values$querychat_title <- current_title()
state$values$querychat_has_greeted <- has_greeted()
if (!is.null(current_greeting())) {
state$values$querychat_greeting <- current_greeting()
}
if (length(viz_widgets) > 0) {
state$values$querychat_viz_widgets <- viz_widgets
}
Expand All @@ -155,8 +183,16 @@ mod_server <- function(
if (!is.null(state$values$querychat_title)) {
current_title(state$values$querychat_title)
}
if (!is.null(state$values$querychat_has_greeted)) {
has_greeted(state$values$querychat_has_greeted)
if (!is.null(state$values$querychat_greeting)) {
current_greeting(state$values$querychat_greeting)
shinychat::chat_set_greeting(
"chat",
shinychat::chat_greeting(
state$values$querychat_greeting,
dismissible = FALSE
),
session = session
)
}
if (!is.null(state$values$querychat_viz_widgets)) {
restored <- restore_viz_widgets(
Expand Down
Loading