diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md index a3b13e19b..4af063a24 100644 --- a/pkg-py/CHANGELOG.md +++ b/pkg-py/CHANGELOG.md @@ -11,6 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * New `/handoff` slash command: turn selected query and visualization results from your chat session into a downloadable Quarto dashboard, Shiny app, or marimo notebook — with AI-assisted revision, bundled data, and handoffs that survive chat history restores and Shiny bookmarks. +* Added a `.page()` method to `QueryChat` (both Core and Express) that wraps `shinychat.page_chat()` for full-window, "chat-first" apps. The chat owns the page (with conversation history, optional navigation pages, sidebars, and a drawer), and reactive data views can live on secondary pages via `shinychat.chat_nav_panel()`. + + ```python + qc = QueryChat(titanic(), "titanic") + app_ui = qc.page("Titanic Explorer") # Core + ``` + ## [0.7.0] - 2026-07-10 ### New features diff --git a/pkg-py/docs/build-intro.qmd b/pkg-py/docs/build-intro.qmd index ad2570559..cdd191199 100644 --- a/pkg-py/docs/build-intro.qmd +++ b/pkg-py/docs/build-intro.qmd @@ -18,7 +18,7 @@ This is especially valuable when: Regardless of framework, building a custom querychat app follows the same pattern: 1. **Initialize** a `QueryChat` instance with your data -2. **Place** the chat UI in your layout (`.sidebar()` or `.ui()`) +2. **Place** the chat UI in your layout (`.page()`, `.sidebar()`, or `.ui()`) 3. **Access** query state (`.df()`, `.sql()`, `.title()`) to build reactive outputs 4. **Connect** visualizations and tables to the filtered data diff --git a/pkg-py/docs/build.qmd b/pkg-py/docs/build.qmd index 2f2368458..b61dc6bee 100644 --- a/pkg-py/docs/build.qmd +++ b/pkg-py/docs/build.qmd @@ -62,10 +62,11 @@ shiny run shiny-app.py ## Relevant methods -After initializing `QueryChat`, use `.sidebar()` or `.ui()` to place the chat interface in your app. As users interact with the chat, `.df()`, `.sql()`, and `.title()` automatically update to reflect the current query. Any Shiny outputs that depend on these reactive values will re-render automatically. +After initializing `QueryChat`, use `.page()`, `.sidebar()`, or `.ui()` to place the chat interface in your app. As users interact with the chat, `.df()`, `.sql()`, and `.title()` automatically update to reflect the current query. Any Shiny outputs that depend on these reactive values will re-render automatically. | Method | Description | |--------|-------------| +| `.page()` | Create a full-window, chat-first page (wraps `shinychat.page_chat()`) | | `.sidebar()` | Place the chat interface in a sidebar | | `.ui()` | Returns just the chat component for custom placement | | `.df()` | Current filtered/sorted DataFrame | @@ -78,6 +79,28 @@ After initializing `QueryChat`, use `.sidebar()` or `.ui()` to place the chat in Shiny has two modes: **Express** (simple, script-based) and **Core** (explicit UI/server separation). With Core, call `qc.server()` in your server function and access reactives via the returned object (e.g., `qc_vals.df()`). With Express, access them directly on the `QueryChat` instance (e.g., `qc.df()`). ::: +## Chat-first page + +When the chat is the primary way users interact with your app, use `.page()` to give it the full browser window. It wraps [`shinychat.page_chat()`](https://posit-dev.github.io/shinychat/py/reference/page_chat.html), which provides a persistent chat with conversation history, plus optional navigation pages, sidebars, and a drawer. Reactive data views (driven by `.df()`, `.sql()`, `.title()`) work well as secondary pages via `shinychat.chat_nav_panel()`: + +::: {.panel-tabset group="shiny-mode"} + +#### Express + +```python +{{< include /../examples/03-page-express-app.py >}} +``` + +#### Core + +```python +{{< include /../examples/03-page-core-app.py >}} +``` + +::: + +Since `.page()` owns the entire page layout, don't wrap it in another page container (e.g., `ui.page_sidebar()`), and in Express, don't add other top-level UI. If you need the chat embedded alongside other content in a custom layout, use `.sidebar()` or `.ui()` instead. + ## Basic sidebar The most common pattern places chat in the sidebar with your custom filtered views in the main area: diff --git a/pkg-py/examples/03-page-core-app.py b/pkg-py/examples/03-page-core-app.py new file mode 100644 index 000000000..bc04c5318 --- /dev/null +++ b/pkg-py/examples/03-page-core-app.py @@ -0,0 +1,48 @@ +from pathlib import Path + +from querychat import QueryChat +from querychat.data import titanic +from shinychat import chat_nav_panel + +from shiny import App, render, ui + +greeting = Path(__file__).parent / "greeting.md" + +# 1. Provide data source to QueryChat +qc = QueryChat(titanic(), "titanic", greeting=greeting) + +# 2. Create a chat-first page (the chat owns the full window), with the +# reactive data view on a secondary page +app_ui = qc.page( + "Titanic Explorer", + pages_navbar=[ + chat_nav_panel( + "Data", + ui.card( + ui.card_header(ui.output_text("title")), + ui.output_data_frame("data_table"), + fill=True, + ), + value="data", + sidebar=False, + content_width="100%", + ), + ], +) + + +def server(input, output, session): + # 3. Add server logic (to get reactive data frame and title) + qc_vals = qc.server() + + # 4. Use the filtered/sorted data frame reactively + @render.data_frame + def data_table(): + return qc_vals.df() + + @render.text + def title(): + return qc_vals.title() or "Titanic Dataset" + + +app = App(app_ui, server) diff --git a/pkg-py/examples/03-page-express-app.py b/pkg-py/examples/03-page-express-app.py new file mode 100644 index 000000000..95c3d5e88 --- /dev/null +++ b/pkg-py/examples/03-page-express-app.py @@ -0,0 +1,39 @@ +from pathlib import Path + +from querychat.data import titanic +from querychat.express import QueryChat +from shiny.express import render, ui +from shinychat import chat_nav_panel + +greeting = Path(__file__).parent / "greeting.md" + +# 1. Provide data source to QueryChat +qc = QueryChat(titanic(), "titanic", greeting=greeting) + +# 2. Hold the reactive data view for the secondary page (page_chat() owns the +# entire page, so outputs can't live at the top level) +with ui.hold() as data_view, ui.card(fill=True): + with ui.card_header(): + + @render.text + def title(): + return qc.title() or "Titanic Dataset" + + @render.data_frame + def data_table(): + return qc.df() + + +# 3. Create a chat-first page (the chat owns the full window) +qc.page( + "Titanic Explorer", + pages_navbar=[ + chat_nav_panel( + "Data", + data_view, + value="data", + sidebar=False, + content_width="100%", + ), + ], +) diff --git a/pkg-py/src/querychat/_shiny.py b/pkg-py/src/querychat/_shiny.py index c42575fbc..c98b04fd7 100644 --- a/pkg-py/src/querychat/_shiny.py +++ b/pkg-py/src/querychat/_shiny.py @@ -12,7 +12,14 @@ from ._icons import bs_icon from ._querychat_base import DEFAULT_TOOLS, TOOL_GROUPS, QueryChatBase, resolve_client -from ._shiny_module import ServerValues, mod_server, mod_ui +from ._shiny_module import ( + CHAT_ID, + ServerValues, + add_footer_and_class, + mod_page, + mod_server, + mod_ui, +) from ._utils import MISSING, MISSING_TYPE, as_narwhals from ._viz_utils import has_viz_tool @@ -496,6 +503,39 @@ def ui(self, *, id: Optional[str] = None, **kwargs): """ return mod_ui(id or self.id, preload_viz=has_viz_tool(self.tools), **kwargs) + def page(self, title, *, id: Optional[str] = None, **kwargs): + """ + Create a full-window page containing the querychat UI. + + This wraps `shinychat.page_chat()`, making the chat the primary + surface of the app, with optional navigation pages, sidebars, and a + drawer. Use this instead of `.sidebar()` or `.ui()` when the chat + should own the full browser window. + + Parameters + ---------- + title + Page title displayed in the header. When it is a string and + `window_title` is omitted, it is also used as the document title. + id + Optional ID for the QueryChat instance. If not provided, + will use the ID provided at initialization. + **kwargs + Additional arguments passed to `shinychat.page_chat()`. + + Returns + ------- + : + A complete fillable Shiny page suitable for use as a Core app's UI. + + """ + return mod_page( + id or self.id, + title, + preload_viz=has_viz_tool(self.tools), + **kwargs, + ) + def server( self, *, @@ -943,6 +983,58 @@ def ui(self, *, id: Optional[str] = None, **kwargs): self._ensure_server_started() return result + def page(self, title, *, id: Optional[str] = None, **kwargs): + """ + Create a full-window Express page containing the querychat UI. + + This wraps `shinychat.express.page_chat()`, making the chat the + primary surface of the app, with optional navigation pages, sidebars, + and a drawer. Use this instead of `.sidebar()` or `.ui()` when the + chat should own the full browser window. + + Since `page_chat()` owns the entire page layout, this must be the + only top-level UI item in the Express app. + + Parameters + ---------- + title + Page title displayed in the header. When it is a string and + `window_title` is omitted, it is also used as the document title. + id + Optional ID for the QueryChat instance. If not provided, + will use the ID provided at initialization. + **kwargs + Additional arguments passed to `shinychat.express.page_chat()`. + + Returns + ------- + : + The page's chat root, returned so Express can display it. It must + remain the sole top-level UI item: do not assign it to a variable + or wrap it in other UI. + + """ + # namespace_context is absent from shiny.module's __all__ (works at runtime) + from shiny.module import ( + ResolvedId, + namespace_context, # pyright: ignore[reportPrivateImportUsage] + ) + from shinychat.express import page_chat as express_page_chat + + module_id = id or self.id + + # Enter the module namespace explicitly so the extras get namespaced IDs. + with namespace_context(module_id): + kwargs = add_footer_and_class(kwargs, preload_viz=has_viz_tool(self.tools)) + + # express page_chat() renders its shell lazily, after the + # namespace_context has exited, so pre-resolve the chat ID to match + # mod_server()'s module scope. + chat_id = ResolvedId(f"{module_id}-{CHAT_ID}") + result = express_page_chat(title, id=chat_id, **kwargs) + self._ensure_server_started() + return result + def _require_vals(self) -> ServerValues[IntoFrameT]: self._ensure_server_started() if self._vals is None: diff --git a/pkg-py/src/querychat/_shiny_module.py b/pkg-py/src/querychat/_shiny_module.py index 3e6f9f80f..98d3d3cf5 100644 --- a/pkg-py/src/querychat/_shiny_module.py +++ b/pkg-py/src/querychat/_shiny_module.py @@ -77,8 +77,15 @@ def mod_ui(*, preload_viz: bool = False, **kwargs): ) +@module.ui +def mod_page(title, *, preload_viz: bool = False, **kwargs): + return shinychat.page_chat( + title, id=CHAT_ID, **add_footer_and_class(kwargs, preload_viz=preload_viz) + ) + + def querychat_extras(*, preload_viz: bool): - # The footer is the only child-injection point in chat_ui(); + # The footer is the only child-injection point in chat_ui()/page_chat(); # styles.css collapses it when it holds only these extras. return ui.div( querychat_head_content(), @@ -89,6 +96,8 @@ def querychat_extras(*, preload_viz: bool): def add_footer_and_class(kwargs: dict, *, preload_viz: bool) -> dict: + # Builds namespaced IDs: callers outside a module context (Express's + # page()) must wrap this in namespace_context(). user_footer = kwargs.pop("footer", None) extras = querychat_extras(preload_viz=preload_viz) kwargs["footer"] = ( diff --git a/pkg-py/tests/playwright/conftest.py b/pkg-py/tests/playwright/conftest.py index 9d213028c..17d5498a4 100644 --- a/pkg-py/tests/playwright/conftest.py +++ b/pkg-py/tests/playwright/conftest.py @@ -199,6 +199,28 @@ def chat_03_core(page: Page) -> ChatControllerType: return _create_chat_controller(page, "titanic") +app_03_page_express = create_app_fixture( + EXAMPLES_DIR / "03-page-express-app.py", scope="module" +) + + +@pytest.fixture +def chat_03_page_express(page: Page) -> ChatControllerType: + """Create a ChatController for the 03-page-express-app chat component.""" + return _create_chat_controller(page, "titanic") + + +app_03_page_core = create_app_fixture( + EXAMPLES_DIR / "03-page-core-app.py", scope="module" +) + + +@pytest.fixture +def chat_03_page_core(page: Page) -> ChatControllerType: + """Create a ChatController for the 03-page-core-app chat component.""" + return _create_chat_controller(page, "titanic") + + def _start_streamlit_app_subprocess( app_path: str, port: int ) -> tuple[subprocess.Popen, None]: diff --git a/pkg-py/tests/playwright/test_03_page_apps.py b/pkg-py/tests/playwright/test_03_page_apps.py new file mode 100644 index 000000000..df23f2494 --- /dev/null +++ b/pkg-py/tests/playwright/test_03_page_apps.py @@ -0,0 +1,87 @@ +""" +Playwright tests for 03-page-express-app.py and 03-page-core-app.py. + +These examples use qc.page() for a chat-first layout with: +- Chat owning the full browser window +- Reactive title and data table on a secondary "Data" navigation page +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from playwright.sync_api import expect +from shinychat.playwright import PageChatController + +if TYPE_CHECKING: + from playwright.sync_api import Page + from shiny.run import ShinyAppProc + from shinychat.playwright import ChatController + + +class PageAppSmoke: + """Shared smoke tests for the chat-first page examples.""" + + page: Page + chat: ChatController + page_shell: PageChatController + + def test_page_title(self) -> None: + """Page has correct title.""" + expect(self.page).to_have_title("Titanic Explorer") + + def test_chat_is_mounted(self) -> None: + """Chat container is visible on the home page.""" + expect(self.chat.loc).to_be_visible() + + def test_data_nav_panel(self) -> None: + """Navigating to the Data page reveals the reactive data view.""" + self.page_shell.select_page("Data") + self.page_shell.expect_active_page("data") + expect(self.page.locator(".card-header")).to_contain_text("Titanic Dataset") + self.page.wait_for_selector("table tbody tr", timeout=15000) + + # ... and the chat remains mounted after returning home + self.page_shell.return_home() + expect(self.chat.loc).to_be_visible() + + def test_extras_footer_is_collapsed(self) -> None: + """The footer carrying querychat's deps/handoff panel adds no vertical space.""" + footer = self.page.locator( + ".shiny-chat-footer", has=self.page.locator(".querychat-extras") + ) + expect(footer).to_be_attached() + expect(footer).to_have_css("padding-top", "0px") + + +class Test03PageExpress(PageAppSmoke): + """Tests for 03-page-express-app.py - Shiny Express with chat-first page.""" + + @pytest.fixture(autouse=True) + def setup( + self, + page: Page, + app_03_page_express: ShinyAppProc, + chat_03_page_express: ChatController, + ) -> None: + page.goto(app_03_page_express.url) + self.page = page + self.chat = chat_03_page_express + self.page_shell = PageChatController(page, "querychat_titanic-chat") + + +class Test03PageCore(PageAppSmoke): + """Tests for 03-page-core-app.py - Shiny Core with chat-first page.""" + + @pytest.fixture(autouse=True) + def setup( + self, + page: Page, + app_03_page_core: ShinyAppProc, + chat_03_page_core: ChatController, + ) -> None: + page.goto(app_03_page_core.url) + self.page = page + self.chat = chat_03_page_core + self.page_shell = PageChatController(page, "querychat_titanic-chat") diff --git a/pkg-py/tests/test_page.py b/pkg-py/tests/test_page.py new file mode 100644 index 000000000..01ce7c673 --- /dev/null +++ b/pkg-py/tests/test_page.py @@ -0,0 +1,170 @@ +"""Tests for QueryChat's .page() method (shinychat.page_chat() wrapper).""" + +from __future__ import annotations + +import os +import re + +import pytest + + +@pytest.fixture(autouse=True) +def set_dummy_api_key(): + old = os.environ.get("OPENAI_API_KEY") + os.environ["OPENAI_API_KEY"] = "sk-dummy" + yield + if old is not None: + os.environ["OPENAI_API_KEY"] = old + else: + del os.environ["OPENAI_API_KEY"] + + +def chat_container(html: str) -> str: + m = re.search(r"]*>", html) + assert m, "no found in rendered page" + return m.group(0) + + +class TestCorePage: + def test_page_renders_namespaced_chat(self): + from querychat import QueryChat + + qc = QueryChat(None, "users") + html = str(qc.page("Test App")) + + # The chat root ID must match what .server() (i.e., mod_server) expects + tag = chat_container(html) + assert 'id="querychat_users-chat"' in tag + # querychat's CSS/JS relies on the `querychat` class on the chat root + assert "querychat" in tag + # The page shell derives its IDs from the chat ID + assert 'id="querychat_users-chat_page"' in html + + def test_page_custom_id(self): + from querychat import QueryChat + + qc = QueryChat(None, "users") + html = str(qc.page("Test App", id="custom")) + assert 'id="custom-chat"' in chat_container(html) + + def test_page_merges_user_class(self): + from querychat import QueryChat + + qc = QueryChat(None, "users") + tag = chat_container(str(qc.page("Test App", class_="extra"))) + assert "querychat" in tag + assert "extra" in tag + + def test_page_defers_cancel_and_attachment_defaults_to_shinychat(self): + from querychat import QueryChat + + qc = QueryChat(None, "users") + tag = chat_container(str(qc.page("Test App"))) + # The attributes are omitted so shinychat's `client=`-based + # auto-enable (update_cancel/update_upload at session start) applies. + # mod_server() always constructs its Chat with a client. + assert "enable-cancel" not in tag + assert "allow-attachments" not in tag + + def test_page_rejects_page_owned_args(self): + from querychat import QueryChat + + qc = QueryChat(None, "users") + with pytest.raises(TypeError, match="owns"): + qc.page("Test App", height="100px") + + def test_page_renders_as_app_ui(self): + from querychat import QueryChat + + from shiny import App + + qc = QueryChat(None, "users") + + def server(input, output, session): + pass + + app = App(qc.page("Test App"), server) + rendered = app.ui + html = rendered["html"] if isinstance(rendered, dict) else str(rendered) + assert " str: + from shiny.express._run import run_express + from shiny.express._stub_session import ExpressStubSession + from shiny.session import session_context + + app_file = tmp_path / "app.py" + app_file.write_text(app_source) + with session_context(ExpressStubSession()): + return str(run_express(app_file)) + + def test_express_page_renders_namespaced_chat(self, tmp_path): + html = self.run_app( + tmp_path, + ( + "from querychat.express import QueryChat\n" + "qc = QueryChat(None, 'users')\n" + 'qc.page("Test App")\n' + ), + ) + + tag = chat_container(html) + assert 'id="querychat_users-chat"' in tag + assert "querychat" in tag + assert 'id="querychat_users-chat_page"' in html + + def test_express_page_merges_user_footer(self, tmp_path): + html = self.run_app( + tmp_path, + ( + "from querychat.express import QueryChat\n" + "from shiny import ui\n" + "qc = QueryChat(None, 'users')\n" + 'qc.page("Test App", footer=ui.div(id="my-footer"))\n' + ), + ) + assert 'id="my-footer"' in html + # querychat's dependencies are still injected alongside the user footer + assert "querychat" in html + + def test_express_page_includes_handoff_panel(self, tmp_path): + html = self.run_app( + tmp_path, + ( + "from querychat.express import QueryChat\n" + "qc = QueryChat(None, 'users')\n" + 'qc.page("Test App")\n' + ), + ) + # The panel is injected via page_chat(footer=) with the other extras + assert 'id="querychat_users-handoff_download"' in html diff --git a/pkg-r/NEWS.md b/pkg-r/NEWS.md index 90572fdc6..e6c29eb1c 100644 --- a/pkg-r/NEWS.md +++ b/pkg-r/NEWS.md @@ -4,6 +4,13 @@ * New `/handoff` slash command: turn selected query and visualization results from your chat session into a downloadable Quarto dashboard or Shiny app — with AI-assisted revision, bundled data, and handoffs that survive chat history restores and Shiny bookmarks. +* Added a `$page()` method to `QueryChat` that wraps `shinychat::page_chat()` for full-window, "chat-first" apps. The chat owns the page (with conversation history, optional navigation pages, sidebars, and a drawer), and reactive data views can live on secondary pages via `shinychat::chat_nav_panel()`. + + ```r + qc <- QueryChat$new(penguins) + ui <- qc$page("Penguins Explorer") + ``` + * The SQL panel in `querychat_app()` is now an editable code editor. Users can tweak the generated SQL directly and apply it with Ctrl/Cmd+Enter or by clicking away — no extra button required. The editor stays in sync when the LLM updates the query or the active table changes. (#265) * `QueryChat$new()` now supports **multiple related tables**. Register additional tables with `$add_table()` and the LLM can reason across all of them — joins, cross-table filters, aggregations. Per-table reactive state (`$df()`, `$sql()`, `$title()`) is accessible via `qc_vals$table("name")` on the list returned by `$server()`. For DBI connections, `$add_tables()` registers all tables (or a named subset) in a single call. (#195) diff --git a/pkg-r/R/QueryChat.R b/pkg-r/R/QueryChat.R index ea930f49e..0e3cf7ec6 100644 --- a/pkg-r/R/QueryChat.R +++ b/pkg-r/R/QueryChat.R @@ -946,6 +946,37 @@ QueryChat <- R6::R6Class( mod_ui(id, ...) }, + #' @description + #' Create a full-window page containing the querychat UI. + #' + #' This wraps [shinychat::page_chat()], making the chat the primary + #' surface of the app, with optional navigation pages, sidebars, and a + #' drawer. Use this instead of `$sidebar()` or `$ui()` when the chat + #' should own the full browser window. + #' + #' @param title Page title displayed in the header. When it is a string + #' and `window_title` is omitted, it is also used as the document title. + #' @param ... Additional arguments passed to [shinychat::page_chat()]. + #' @param id Optional ID for the QueryChat instance. + #' + #' @return A fillable page UI component suitable for use as the app's UI. + page = function(title, ..., id = NULL) { + check_string(id, allow_null = TRUE, allow_empty = FALSE) + + id <- id %||% namespaced_id(self$id) + + ns <- shiny::NS(id) + # Extras must ride in the footer slot; tagList() siblings of + # page_chat() would render outside . + dots <- add_footer_and_class(rlang::list2(...), ns) + rlang::exec( + shinychat::page_chat, + title, + !!!dots, + id = ns("chat") + ) + }, + #' @description #' Initialize the querychat server logic. #' diff --git a/pkg-r/inst/examples-shiny/10-viz-app/app.R b/pkg-r/inst/examples-shiny/10-viz-app/app.R index fbed89e73..1ddf335ee 100644 --- a/pkg-r/inst/examples-shiny/10-viz-app/app.R +++ b/pkg-r/inst/examples-shiny/10-viz-app/app.R @@ -1,32 +1,15 @@ -library(shiny) -library(bslib) library(querychat) library(palmerpenguins) qc <- QueryChat$new( penguins, - tools = c("update", "query", "visualize"), - data_description = paste( - "The Palmer Penguins dataset contains measurements of bill", - "dimensions, flipper length, body mass, sex, and species", - "(Adelie, Chinstrap, and Gentoo) collected from three islands in", - "the Palmer Archipelago, Antarctica." - ) + tools = c("update", "query", "visualize") ) -ui <- page_sidebar( - title = "querychat viz demo", - sidebar = qc$sidebar(width = 400, open = TRUE, position = "right"), - card( - full_screen = TRUE, - card_header("Data"), - DT::DTOutput("dt") - ) -) +ui <- qc$page("querychat viz demo") server <- function(input, output, session) { - qc_vals <- qc$server() - output$dt <- DT::renderDT(qc_vals$df(), fillContainer = TRUE) + qc$server() } -shinyApp(ui, server) +shiny::shinyApp(ui, server) diff --git a/pkg-r/man/QueryChat.Rd b/pkg-r/man/QueryChat.Rd index 548dcae23..f7a144fc1 100644 --- a/pkg-r/man/QueryChat.Rd +++ b/pkg-r/man/QueryChat.Rd @@ -134,6 +134,7 @@ access its \verb{$tables} and \verb{$prompt}.} \item \href{#method-QueryChat-app_obj}{\code{QueryChat$app_obj()}} \item \href{#method-QueryChat-sidebar}{\code{QueryChat$sidebar()}} \item \href{#method-QueryChat-ui}{\code{QueryChat$ui()}} + \item \href{#method-QueryChat-page}{\code{QueryChat$page()}} \item \href{#method-QueryChat-server}{\code{QueryChat$server()}} \item \href{#method-QueryChat-generate_greeting}{\code{QueryChat$generate_greeting()}} \item \href{#method-QueryChat-cleanup}{\code{QueryChat$cleanup()}} @@ -512,6 +513,36 @@ See \verb{$app()}.} } } +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-QueryChat-page}{}}} +\subsection{\code{QueryChat$page()}}{ + Create a full-window page containing the querychat UI. + +This wraps \code{\link[shinychat:page_chat]{shinychat::page_chat()}}, making the chat the primary +surface of the app, with optional navigation pages, sidebars, and a +drawer. Use this instead of \verb{$sidebar()} or \verb{$ui()} when the chat +should own the full browser window. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{QueryChat$page(title, ..., id = NULL)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{title}}{Page title displayed in the header. When it is a string +and \code{window_title} is omitted, it is also used as the document title.} + \item{\code{...}}{Additional arguments passed to \code{\link[shinychat:page_chat]{shinychat::page_chat()}}.} + \item{\code{id}}{Optional ID for the QueryChat instance.} + } + \if{html}{\out{
}} + } + \subsection{Returns}{ + A fillable page UI component suitable for use as the app's UI. + } +} + \if{html}{\out{
}} \if{html}{\out{}} \if{latex}{\out{\hypertarget{method-QueryChat-server}{}}} diff --git a/pkg-r/tests/testthat/test-QueryChat.R b/pkg-r/tests/testthat/test-QueryChat.R index a38818db0..1e15a46c5 100644 --- a/pkg-r/tests/testthat/test-QueryChat.R +++ b/pkg-r/tests/testthat/test-QueryChat.R @@ -1477,3 +1477,106 @@ describe("QueryChatGreeter", { expect_true("customers" %in% qc$greeter$tables) }) }) + +describe("QueryChat$page()", { + skip_if_not_installed("shinychat", minimum_version = "0.4.0.9000") + + it("renders a full-window page with a namespaced chat root", { + qc <- QueryChat$new(NULL, "users", greeting = "Test") + html <- as.character(qc$page("Test App")) + + # The chat root ID must match what $server() (i.e., mod_server) expects + expect_true(grepl('id="querychat_users-chat"', html, fixed = TRUE)) + # The page shell derives its IDs from the chat ID + expect_true(grepl('id="querychat_users-chat_page"', html, fixed = TRUE)) + }) + + it("adds the querychat class to the chat root, merging user classes", { + qc <- QueryChat$new(NULL, "users", greeting = "Test") + + # Assert class tokens are present regardless of order, since shinychat + # controls how classes are ordered/merged + class_attrs <- function(html) { + regmatches(html, gregexpr('class="[^"]*"', html))[[1]] + } + + html <- as.character(qc$page("Test App")) + expect_true(any(grepl("\\bquerychat\\b", class_attrs(html)))) + + html_extra <- as.character(qc$page("Test App", class = "extra")) + attrs <- class_attrs(html_extra) + expect_true(any( + grepl("\\bquerychat\\b", attrs) & grepl("\\bextra\\b", attrs) + )) + }) + + it("respects a custom id", { + qc <- QueryChat$new(NULL, "users", greeting = "Test") + html <- as.character(qc$page("Test App", id = "custom")) + expect_true(grepl('id="custom-chat"', html, fixed = TRUE)) + }) + + it("includes the handoff panel", { + qc <- QueryChat$new(NULL, "users", greeting = "Test") + # mod_server() always wires handoff_server(), so the panel must exist + html <- as.character(qc$page("Test App")) + expect_true(grepl( + 'id="querychat_users-handoff_download"', + html, + fixed = TRUE + )) + }) + + it("includes the querychat HTML dependency", { + qc <- QueryChat$new(NULL, "users", greeting = "Test") + deps <- htmltools::findDependencies(qc$page("Test App")) + expect_true("querychat" %in% vapply(deps, `[[`, "", "name")) + }) + + it("rejects page-owned chat_ui arguments", { + qc <- QueryChat$new(NULL, "users", greeting = "Test") + expect_error(qc$page("Test App", height = "100px"), "owns") + }) + + it("injects dependencies and the handoff panel via the chat footer", { + qc <- QueryChat$new(NULL, "users", greeting = "Test") + html <- as.character(qc$page("Test App")) + expect_true(grepl("shiny-chat-footer", html, fixed = TRUE)) + expect_true(grepl("querychat-extras", html, fixed = TRUE)) + expect_true(grepl( + 'id="querychat_users-handoff_download"', + html, + fixed = TRUE + )) + }) + + it("merges user-supplied footer content", { + qc <- QueryChat$new(NULL, "users", greeting = "Test") + html <- as.character(qc$page( + "Test App", + footer = htmltools::div(id = "my-footer") + )) + expect_true(grepl('id="my-footer"', html, fixed = TRUE)) + expect_true(grepl("querychat-extras", html, fixed = TRUE)) + }) +}) + +describe("QueryChat$ui()", { + skip_if_not_installed("shinychat", minimum_version = "0.4.0.9000") + + it("injects dependencies and the handoff panel via the chat footer", { + qc <- QueryChat$new(NULL, "users", greeting = "Test") + html <- as.character(qc$ui()) + expect_true(grepl("shiny-chat-footer", html, fixed = TRUE)) + expect_true(grepl("querychat-extras", html, fixed = TRUE)) + deps <- htmltools::findDependencies(qc$ui()) + expect_true("querychat" %in% vapply(deps, `[[`, "", "name")) + }) + + it("merges user-supplied footer content", { + qc <- QueryChat$new(NULL, "users", greeting = "Test") + html <- as.character(qc$ui(footer = htmltools::div(id = "my-footer"))) + expect_true(grepl('id="my-footer"', html, fixed = TRUE)) + expect_true(grepl("querychat-extras", html, fixed = TRUE)) + }) +}) diff --git a/pkg-r/vignettes/build.Rmd b/pkg-r/vignettes/build.Rmd index 49b97bbee..f4ef3cdf7 100644 --- a/pkg-r/vignettes/build.Rmd +++ b/pkg-r/vignettes/build.Rmd @@ -32,7 +32,7 @@ This is especially valuable when: Integrating querychat into a Shiny app requires just three steps: 1. Initialize a `QueryChat` instance with your data -2. Add the UI component (either `$sidebar()` or `$ui()`) +2. Add the UI component (`$page()`, `$sidebar()`, or `$ui()`) 3. Use reactive values like `$df()`, `$sql()`, and `$title()` to build outputs that respond to user queries Here's a starter template demonstrating these steps: @@ -81,6 +81,53 @@ shinyApp(ui, server) You'll need to call the `qc$server()` method within your server function to set up querychat's reactive behavior, and capture its return value to access reactive data. ::: +## Chat-first page {#chat-first-page} + +The starter template above uses `$sidebar()` to embed the chat alongside your data views. When the chat is the primary way users interact with your app, use `$page()` instead to give it the full browser window. It wraps [shinychat::page_chat()](https://posit-dev.github.io/shinychat/r/reference/page_chat.html), which provides a persistent chat with conversation history, plus optional navigation pages, sidebars, and a drawer. Reactive data views (driven by `$df()`, `$sql()`, `$title()`) work well as secondary pages via `shinychat::chat_nav_panel()`: + +```{r} +library(shiny) +library(bslib) +library(querychat) +library(DT) +library(palmerpenguins) + +qc <- QueryChat$new(penguins) + +ui <- qc$page( + "Penguins Explorer", + pages_navbar = list( + shinychat::chat_nav_panel( + "Data", + card( + card_header(textOutput("title")), + dataTableOutput("table"), + fill = TRUE + ), + value = "data", + sidebar = FALSE, + content_width = "100%" + ) + ) +) + +server <- function(input, output, session) { + qc_vals <- qc$server() + + output$table <- renderDataTable({ + datatable(qc_vals$df(), fillContainer = TRUE) + }) + + output$title <- renderText({ + qc_vals$title() %||% "Penguins Dataset" + }) +} + +shinyApp(ui, server) +``` + +Since `$page()` owns the entire page layout, don't wrap it in another page container (e.g., `page_sidebar()`). If you need the chat embedded alongside other content in a custom layout, use `$sidebar()` or `$ui()` instead. + ## Deferred data sources {#deferred-data-sources} Some data sources, like database connections or reactive calculations, may need to be created within an active Shiny session. To help support this, `QueryChat` allows you to initialize without a data source and provide it later, like this: