Skip to content

Commit f1aef08

Browse files
cpsievertclaude
andcommitted
feat(querychat): inline Altair charts in visualize_query tool results
Add VisualizeQueryResult subclass that embeds Altair charts directly in chat tool result cards via shinywidgets register_widget/output_widget. Includes e2e Playwright tests for inline rendering and fullscreen. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9160e24 commit f1aef08

5 files changed

Lines changed: 249 additions & 22 deletions

File tree

pkg-py/examples/10-viz-app.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from pathlib import Path
2+
3+
from querychat import QueryChat
4+
from querychat.data import titanic
5+
6+
from shiny import App, render, ui
7+
8+
greeting = Path(__file__).parent / "greeting.md"
9+
10+
qc = QueryChat(
11+
titanic(),
12+
"titanic",
13+
greeting=greeting,
14+
tools=("update", "query", "visualize_query"),
15+
)
16+
17+
app_ui = ui.page_sidebar(
18+
qc.sidebar(),
19+
ui.card(
20+
ui.card_header(ui.output_text("title")),
21+
ui.output_data_frame("data_table"),
22+
fill=True,
23+
),
24+
fillable=True,
25+
)
26+
27+
28+
def server(input, output, session):
29+
qc_vals = qc.server()
30+
31+
@render.data_frame
32+
def data_table():
33+
return qc_vals.df()
34+
35+
@render.text
36+
def title():
37+
return qc_vals.title() or "Titanic Dataset"
38+
39+
40+
app = App(app_ui, server)

pkg-py/src/querychat/tools.py

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from pathlib import Path
44
from typing import TYPE_CHECKING, Any, Protocol, TypedDict, runtime_checkable
5+
from uuid import uuid4
56

67
import chevron
78
from chatlas import ContentToolResult, Tool
@@ -309,6 +310,42 @@ def tool_query(data_source: DataSource) -> Tool:
309310
)
310311

311312

313+
class VisualizeQueryResult(ContentToolResult):
314+
"""Tool result that embeds an Altair chart inline via shinywidgets."""
315+
316+
def __init__(
317+
self,
318+
chart: Any,
319+
ggsql_str: str,
320+
title: str | None,
321+
row_count: int,
322+
col_count: int,
323+
**kwargs: Any,
324+
):
325+
from shinywidgets import output_widget, register_widget
326+
327+
widget_id = f"querychat_viz_{uuid4().hex[:8]}"
328+
register_widget(widget_id, chart)
329+
330+
title_display = f" - {title}" if title else ""
331+
markdown = f"```sql\n{ggsql_str}\n```"
332+
markdown += f"\n\nVisualization created{title_display}."
333+
markdown += f"\n\nData: {row_count} rows, {col_count} columns."
334+
335+
extra = {
336+
"display": ToolResultDisplay(
337+
html=output_widget(widget_id),
338+
title=title or "Query Visualization",
339+
show_request=False,
340+
open=True,
341+
full_screen=True,
342+
icon=bs_icon("graph-up"),
343+
),
344+
}
345+
346+
super().__init__(value=markdown, extra=extra, **kwargs)
347+
348+
312349
def _visualize_query_impl(
313350
data_source: DataSource,
314351
update_fn: Callable[[VisualizeQueryData], None],
@@ -334,41 +371,31 @@ def visualize_query(
334371
"Use querychat_query for queries without visualization."
335372
)
336373

337-
# Execute the SQL and validate by rendering
374+
# Execute the SQL and render the visualization
338375
spec = execute_ggsql(data_source, ggsql)
339-
spec_to_altair(spec)
376+
chart = spec_to_altair(spec)
340377

341-
# Extract title from spec if not provided
342378
if title is None:
343379
title = extract_title(spec)
344380
metadata = spec.metadata()
345381
row_count = metadata["rows"]
346382
col_count = len(metadata["columns"])
347383

348-
# Store just the ggsql - rendering happens on display
349384
update_fn(
350385
{
351386
"ggsql": ggsql,
352387
"title": title,
353388
}
354389
)
355390

356-
# Format success message with data summary
357-
title_display = f" - {title}" if title else ""
358-
markdown += f"\n\nVisualization created{title_display}."
359-
markdown += f"\n\nData: {row_count} rows, {col_count} columns."
360-
361-
return ContentToolResult(
362-
value=markdown,
363-
extra={
364-
"display": ToolResultDisplay(
365-
markdown=markdown,
366-
title=title or "Query Visualization",
367-
show_request=False,
368-
open=querychat_tool_starts_open("visualize_query"),
369-
icon=bs_icon("graph-up"),
370-
),
371-
},
391+
chart = chart.properties(height=300, width="container")
392+
393+
return VisualizeQueryResult(
394+
chart=chart,
395+
ggsql_str=ggsql,
396+
title=title,
397+
row_count=row_count,
398+
col_count=col_count,
372399
)
373400

374401
except Exception as e:

pkg-py/tests/playwright/conftest.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,3 +592,31 @@ def dash_cleanup(_thread, server):
592592
yield url
593593
finally:
594594
_stop_dash_server(server)
595+
596+
597+
@pytest.fixture(scope="module")
598+
def app_10_viz() -> Generator[str, None, None]:
599+
"""Start the 10-viz-app.py Shiny server for testing."""
600+
app_path = str(EXAMPLES_DIR / "10-viz-app.py")
601+
602+
def start_factory():
603+
port = _find_free_port()
604+
url = f"http://localhost:{port}"
605+
return url, lambda: _start_shiny_app_threaded(app_path, port)
606+
607+
def shiny_cleanup(_thread, server):
608+
_stop_shiny_server(server)
609+
610+
url, _thread, server = _start_server_with_retry(
611+
start_factory, shiny_cleanup, timeout=30.0
612+
)
613+
try:
614+
yield url
615+
finally:
616+
_stop_shiny_server(server)
617+
618+
619+
@pytest.fixture
620+
def chat_10_viz(page: Page) -> ChatControllerType:
621+
"""Create a ChatController for the 10-viz-app chat component."""
622+
return _create_chat_controller(page, "titanic")
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""
2+
Playwright tests for inline visualization and fullscreen behavior.
3+
4+
These tests verify that:
5+
1. The visualize_query tool renders Altair charts inline in tool result cards
6+
2. The fullscreen toggle button appears on visualization tool results
7+
3. Fullscreen mode works (expand and collapse via button and Escape key)
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from typing import TYPE_CHECKING
13+
14+
import pytest
15+
from playwright.sync_api import expect
16+
17+
if TYPE_CHECKING:
18+
from playwright.sync_api import Page
19+
from shinychat.playwright import ChatController
20+
21+
22+
class TestInlineVisualization:
23+
"""Tests for inline chart rendering in tool result cards."""
24+
25+
@pytest.fixture(autouse=True)
26+
def setup(
27+
self, page: Page, app_10_viz: str, chat_10_viz: ChatController
28+
) -> None:
29+
"""Navigate to the viz app before each test."""
30+
page.goto(app_10_viz)
31+
page.wait_for_selector("table", timeout=30000)
32+
self.page = page
33+
self.chat = chat_10_viz
34+
35+
def test_app_loads_with_query_plot_tab(self) -> None:
36+
"""VIZ-INIT: App with visualize_query has a Query Plot tab."""
37+
expect(self.page.get_by_role("tab", name="Query Plot")).to_be_visible()
38+
39+
def test_viz_tool_renders_inline_chart(self) -> None:
40+
"""VIZ-INLINE: Visualization tool result contains an inline chart widget."""
41+
self.chat.set_user_input(
42+
"Create a scatter plot of age vs fare for the titanic passengers"
43+
)
44+
self.chat.send_user_input(method="click")
45+
46+
# Wait for a tool result card with full-screen attribute (viz results have it)
47+
tool_card = self.page.locator("shiny-tool-result[full-screen]")
48+
expect(tool_card).to_be_visible(timeout=90000)
49+
50+
# The card should contain a widget output (Altair chart)
51+
widget_output = tool_card.locator(".jupyter-widgets")
52+
expect(widget_output).to_be_visible(timeout=10000)
53+
54+
def test_fullscreen_button_visible_on_viz_card(self) -> None:
55+
"""VIZ-FS-BTN: Fullscreen toggle button appears on visualization cards."""
56+
self.chat.set_user_input(
57+
"Make a bar chart showing count of passengers by class"
58+
)
59+
self.chat.send_user_input(method="click")
60+
61+
# Wait for viz tool result
62+
tool_card = self.page.locator("shiny-tool-result[full-screen]")
63+
expect(tool_card).to_be_visible(timeout=90000)
64+
65+
# Fullscreen toggle should be visible
66+
fs_button = tool_card.locator(".tool-fullscreen-toggle")
67+
expect(fs_button).to_be_visible()
68+
69+
def test_fullscreen_toggle_expands_card(self) -> None:
70+
"""VIZ-FS-EXPAND: Clicking fullscreen button expands the card."""
71+
self.chat.set_user_input(
72+
"Plot a histogram of passenger ages from the titanic data"
73+
)
74+
self.chat.send_user_input(method="click")
75+
76+
# Wait for viz tool result
77+
tool_result = self.page.locator("shiny-tool-result[full-screen]")
78+
expect(tool_result).to_be_visible(timeout=90000)
79+
80+
# Click fullscreen toggle
81+
fs_button = tool_result.locator(".tool-fullscreen-toggle")
82+
fs_button.click()
83+
84+
# The .shiny-tool-card inside should now have fullscreen attribute
85+
card = tool_result.locator(".shiny-tool-card[fullscreen]")
86+
expect(card).to_be_visible()
87+
88+
def test_escape_closes_fullscreen(self) -> None:
89+
"""VIZ-FS-ESC: Pressing Escape closes fullscreen mode."""
90+
self.chat.set_user_input(
91+
"Create a visualization of survival rate by passenger class"
92+
)
93+
self.chat.send_user_input(method="click")
94+
95+
# Wait for viz tool result
96+
tool_result = self.page.locator("shiny-tool-result[full-screen]")
97+
expect(tool_result).to_be_visible(timeout=90000)
98+
99+
# Enter fullscreen
100+
fs_button = tool_result.locator(".tool-fullscreen-toggle")
101+
fs_button.click()
102+
103+
card = tool_result.locator(".shiny-tool-card[fullscreen]")
104+
expect(card).to_be_visible()
105+
106+
# Press Escape
107+
self.page.keyboard.press("Escape")
108+
109+
# Fullscreen should be removed
110+
expect(card).not_to_be_visible()
111+
112+
def test_non_viz_tool_results_have_no_fullscreen(self) -> None:
113+
"""VIZ-NO-FS: Non-visualization tool results don't have fullscreen."""
114+
self.chat.set_user_input("Show me passengers who survived")
115+
self.chat.send_user_input(method="click")
116+
117+
# Wait for a tool result (any)
118+
tool_result = self.page.locator("shiny-tool-result").first
119+
expect(tool_result).to_be_visible(timeout=90000)
120+
121+
# Non-viz tool results should NOT have full-screen attribute
122+
fs_results = self.page.locator("shiny-tool-result[full-screen]")
123+
expect(fs_results).to_have_count(0)

pkg-py/tests/test_viz_tools.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from querychat._datasource import DataFrameSource
1010
from querychat.tools import (
1111
VisualizeQueryData,
12+
VisualizeQueryResult,
1213
tool_visualize_query,
1314
)
1415

@@ -67,16 +68,19 @@ def update_fn(data: VisualizeQueryData):
6768
assert tool.name == "querychat_visualize_query"
6869

6970
@ggsql_render_works
70-
def test_tool_executes_sql_and_renders(self, data_source):
71+
def test_tool_executes_sql_and_renders(self, data_source, monkeypatch):
7172
callback_data = {}
7273

7374
def update_fn(data: VisualizeQueryData):
7475
callback_data.update(data)
7576

77+
monkeypatch.setattr("shinywidgets.register_widget", lambda _widget_id, _chart: None)
78+
monkeypatch.setattr("shinywidgets.output_widget", lambda widget_id: widget_id)
79+
7680
tool = tool_visualize_query(data_source, update_fn)
7781
impl = tool.func
7882

79-
impl(
83+
result = impl(
8084
ggsql="SELECT x, y FROM test_data WHERE x > 2 VISUALISE x, y DRAW point",
8185
title="Filtered Scatter",
8286
)
@@ -85,6 +89,11 @@ def update_fn(data: VisualizeQueryData):
8589
assert "title" in callback_data
8690
assert callback_data["title"] == "Filtered Scatter"
8791

92+
assert isinstance(result, VisualizeQueryResult)
93+
display = result.extra["display"]
94+
assert display.full_screen is True
95+
assert display.open is True
96+
8897
@ggsql_render_works
8998
def test_tool_handles_query_without_visualise(self, data_source):
9099
callback_data = {}

0 commit comments

Comments
 (0)