Skip to content
Open
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
7 changes: 7 additions & 0 deletions pkg-py/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pkg-py/docs/build-intro.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 24 additions & 1 deletion pkg-py/docs/build.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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:
Expand Down
48 changes: 48 additions & 0 deletions pkg-py/examples/03-page-core-app.py
Original file line number Diff line number Diff line change
@@ -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)
39 changes: 39 additions & 0 deletions pkg-py/examples/03-page-express-app.py
Original file line number Diff line number Diff line change
@@ -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%",
),
],
)
94 changes: 93 additions & 1 deletion pkg-py/src/querychat/_shiny.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
*,
Expand Down Expand Up @@ -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:
Expand Down
9 changes: 8 additions & 1 deletion pkg-py/src/querychat/_shiny_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
22 changes: 22 additions & 0 deletions pkg-py/tests/playwright/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
Loading
Loading