diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md index 6600542b..16d58f10 100644 --- a/pkg-py/CHANGELOG.md +++ b/pkg-py/CHANGELOG.md @@ -31,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * querychat's system prompt and tool descriptions were rewritten for clarity and future extensibility. (#90) +* Tool detail cards can now be expanded or collapsed by default when querychat runs a query or updates the dashboard via the `QUERYCHAT_TOOL_DETAILS` environment variable. Valid values are `"expanded"`, `"collapsed"`, or `"default"`. (#137) + ## [0.2.2] - 2025-09-04 * Fixed another issue with data sources that aren't already narwhals DataFrames (#83) diff --git a/pkg-py/src/querychat/_utils.py b/pkg-py/src/querychat/_utils.py index 1cfb3473..f24b3832 100644 --- a/pkg-py/src/querychat/_utils.py +++ b/pkg-py/src/querychat/_utils.py @@ -1,8 +1,9 @@ from __future__ import annotations import os +import warnings from contextlib import contextmanager -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Literal, Optional import narwhals.stable.v1 as nw @@ -53,6 +54,65 @@ def temp_env_vars(env_vars: dict[str, Optional[str]]): os.environ[key] = original_value +def get_tool_details_setting() -> Optional[Literal["expanded", "collapsed", "default"]]: + """ + Get and validate the tool details setting from environment variable. + + Returns + ------- + Optional[str] + The validated value of QUERYCHAT_TOOL_DETAILS environment variable + (one of 'expanded', 'collapsed', or 'default'), or None if not set + or invalid + + """ + setting = os.environ.get("QUERYCHAT_TOOL_DETAILS") + if setting is None: + return None + + setting_lower = setting.lower() + valid_settings = ("expanded", "collapsed", "default") + + if setting_lower not in valid_settings: + warnings.warn( + f"Invalid value for QUERYCHAT_TOOL_DETAILS: {setting!r}. " + "Must be one of: 'expanded', 'collapsed', or 'default'", + UserWarning, + stacklevel=2, + ) + return None + + return setting_lower + + +def querychat_tool_starts_open(action: Literal["update", "query", "reset"]) -> bool: + """ + Determine whether a tool card should be open based on action and setting. + + Parameters + ---------- + action : str + The action type ('update', 'query', or 'reset') + + Returns + ------- + bool + True if the tool card should be open, False otherwise + + """ + setting = get_tool_details_setting() + + if setting is None: + return action != "reset" + + if setting == "expanded": + return True + elif setting == "collapsed": + return False + else: # setting == "default" + return action != "reset" + + def df_to_html(df: IntoFrame, maxrows: int = 5) -> str: """ Convert a DataFrame to an HTML table for display in chat. diff --git a/pkg-py/src/querychat/tools.py b/pkg-py/src/querychat/tools.py index 06970150..be4a326c 100644 --- a/pkg-py/src/querychat/tools.py +++ b/pkg-py/src/querychat/tools.py @@ -8,7 +8,7 @@ from shinychat.types import ToolResultDisplay from ._icons import bs_icon -from ._utils import df_to_html +from ._utils import df_to_html, querychat_tool_starts_open if TYPE_CHECKING: from ._datasource import DataSource @@ -65,7 +65,7 @@ def update_dashboard(query: str, title: str) -> ContentToolResult: markdown=markdown + f"\n\n{button_html}", title=title, show_request=False, - open=True, + open=querychat_tool_starts_open("update"), icon=bs_icon("funnel-fill"), ), }, @@ -139,7 +139,7 @@ def reset_dashboard() -> ContentToolResult: markdown=button_html, title=None, show_request=False, - open=False, + open=querychat_tool_starts_open("reset"), icon=bs_icon("arrow-counterclockwise"), ), }, @@ -208,7 +208,7 @@ def query(query: str, _intent: str = "") -> ContentToolResult: "display": ToolResultDisplay( markdown=markdown, show_request=False, - open=True, + open=querychat_tool_starts_open("query"), icon=bs_icon("table"), ), }, diff --git a/pkg-py/tests/test_tools.py b/pkg-py/tests/test_tools.py new file mode 100644 index 00000000..682f259c --- /dev/null +++ b/pkg-py/tests/test_tools.py @@ -0,0 +1,66 @@ +"""Tests for tool functions and utilities.""" + +import warnings + +from querychat._utils import querychat_tool_starts_open + + +def test_querychat_tool_starts_open_default_behavior(monkeypatch): + """Test default behavior when no setting is provided.""" + monkeypatch.delenv("QUERYCHAT_TOOL_DETAILS", raising=False) + + assert querychat_tool_starts_open("query") is True + assert querychat_tool_starts_open("update") is True + assert querychat_tool_starts_open("reset") is False + + +def test_querychat_tool_starts_open_expanded(monkeypatch): + """Test 'expanded' setting.""" + monkeypatch.setenv("QUERYCHAT_TOOL_DETAILS", "expanded") + + assert querychat_tool_starts_open("query") is True + assert querychat_tool_starts_open("update") is True + assert querychat_tool_starts_open("reset") is True + + +def test_querychat_tool_starts_open_collapsed(monkeypatch): + """Test 'collapsed' setting.""" + monkeypatch.setenv("QUERYCHAT_TOOL_DETAILS", "collapsed") + + assert querychat_tool_starts_open("query") is False + assert querychat_tool_starts_open("update") is False + assert querychat_tool_starts_open("reset") is False + + +def test_querychat_tool_starts_open_default_setting(monkeypatch): + """Test 'default' setting.""" + monkeypatch.setenv("QUERYCHAT_TOOL_DETAILS", "default") + + assert querychat_tool_starts_open("query") is True + assert querychat_tool_starts_open("update") is True + assert querychat_tool_starts_open("reset") is False + + +def test_querychat_tool_starts_open_case_insensitive(monkeypatch): + """Test that setting is case-insensitive.""" + monkeypatch.setenv("QUERYCHAT_TOOL_DETAILS", "EXPANDED") + assert querychat_tool_starts_open("query") is True + + monkeypatch.setenv("QUERYCHAT_TOOL_DETAILS", "Collapsed") + assert querychat_tool_starts_open("query") is False + + monkeypatch.setenv("QUERYCHAT_TOOL_DETAILS", "DeFaUlT") + assert querychat_tool_starts_open("query") is True + + +def test_querychat_tool_starts_open_invalid_setting(monkeypatch): + """Test warning on invalid setting.""" + monkeypatch.setenv("QUERYCHAT_TOOL_DETAILS", "invalid") + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = querychat_tool_starts_open("query") + + assert len(w) == 1 + assert "Invalid value" in str(w[0].message) + assert result is True # Falls back to default behavior diff --git a/pkg-r/NEWS.md b/pkg-r/NEWS.md index 5a9a2cd0..600f3733 100644 --- a/pkg-r/NEWS.md +++ b/pkg-r/NEWS.md @@ -1,5 +1,7 @@ # querychat (development version) +* Tool detail cards can now be expanded or collapsed by default when querychat runs a query or updates the dashboard via the `querychat.tool_details` R option or the `QUERYCHAT_TOOL_DETAILS` environment variable. Valid values are `"expanded"`, `"collapsed"`, or `"default"`. (#137) + * Added bookmarking support to `QueryChat$server()` and `querychat_app()`. When bookmarking is enabled (via `bookmark_store = "url"` or `"server"` in `querychat_app()` or `$app_obj()`, or via `enable_bookmarking = TRUE` in `$server()`), the chat state (including current query, title, and chat history) will be saved and restored with Shiny bookmarks. (#107) * Nearly the entire functional API (i.e., `querychat_init()`, `querychat_sidebar()`, `querychat_server()`, etc) has been hard deprecated in favor of a simpler OOP-based API. Namely, the new `QueryChat$new()` class is now the main entry point (instead of `querychat_init()`) and has methods to replace old functions (e.g., `$sidebar()`, `$server()`, etc). (#109) diff --git a/pkg-r/R/querychat_tools.R b/pkg-r/R/querychat_tools.R index 6b3275b4..28c09e62 100644 --- a/pkg-r/R/querychat_tools.R +++ b/pkg-r/R/querychat_tools.R @@ -108,6 +108,48 @@ tool_query <- function(data_source) { ) } +querychat_tool_details_option <- function() { + opt <- getOption("querychat.tool_details", NULL) + if (!is.null(opt)) { + setting <- opt + } else { + env <- Sys.getenv("QUERYCHAT_TOOL_DETAILS", "") + if (nzchar(env)) { + setting <- env + } else { + return(NULL) + } + } + + setting <- tolower(setting) + valid_settings <- c("expanded", "collapsed", "default") + + if (!setting %in% valid_settings) { + cli::cli_warn(c( + "Invalid value for {.code querychat.tool_details} or {.envvar QUERYCHAT_TOOL_DETAILS}: {.val {setting}}", + "i" = "Must be one of: {.or {.val {valid_settings}}}" + )) + return(NULL) + } + + setting +} + +querychat_tool_starts_open <- function(action) { + setting <- querychat_tool_details_option() + + if (is.null(setting)) { + return(action != "reset") + } + + switch( + setting, + "expanded" = TRUE, + "collapsed" = FALSE, + action != "reset" + ) +} + querychat_tool_result <- function( data_source, query, @@ -182,7 +224,7 @@ querychat_tool_result <- function( title = if (action == "update" && !is.null(title)) title, show_request = is_error, markdown = display_md, - open = !is_error && action != "reset" + open = querychat_tool_starts_open(action) ) ) ) diff --git a/pkg-r/tests/testthat/test-querychat_tools.R b/pkg-r/tests/testthat/test-querychat_tools.R new file mode 100644 index 00000000..271ba20f --- /dev/null +++ b/pkg-r/tests/testthat/test-querychat_tools.R @@ -0,0 +1,74 @@ +test_that("querychat_tool_starts_open respects default behavior", { + withr::local_options(querychat.tool_details = NULL) + withr::local_envvar(QUERYCHAT_TOOL_DETAILS = NA) + + expect_true(querychat_tool_starts_open("query")) + expect_true(querychat_tool_starts_open("update")) + expect_false(querychat_tool_starts_open("reset")) +}) + +test_that("querychat_tool_starts_open respects 'expanded' setting via option", { + withr::local_options(querychat.tool_details = "expanded") + + expect_true(querychat_tool_starts_open("query")) + expect_true(querychat_tool_starts_open("update")) + expect_true(querychat_tool_starts_open("reset")) +}) + +test_that("querychat_tool_starts_open respects 'collapsed' setting via option", { + withr::local_options(querychat.tool_details = "collapsed") + + expect_false(querychat_tool_starts_open("query")) + expect_false(querychat_tool_starts_open("update")) + expect_false(querychat_tool_starts_open("reset")) +}) + +test_that("querychat_tool_starts_open respects 'default' setting via option", { + withr::local_options(querychat.tool_details = "default") + + expect_true(querychat_tool_starts_open("query")) + expect_true(querychat_tool_starts_open("update")) + expect_false(querychat_tool_starts_open("reset")) +}) + +test_that("querychat_tool_starts_open respects 'expanded' setting via envvar", { + withr::local_options(querychat.tool_details = NULL) + withr::local_envvar(QUERYCHAT_TOOL_DETAILS = "expanded") + + expect_true(querychat_tool_starts_open("query")) + expect_true(querychat_tool_starts_open("update")) + expect_true(querychat_tool_starts_open("reset")) +}) + +test_that("querychat_tool_starts_open respects 'collapsed' setting via envvar", { + withr::local_options(querychat.tool_details = NULL) + withr::local_envvar(QUERYCHAT_TOOL_DETAILS = "collapsed") + + expect_false(querychat_tool_starts_open("query")) + expect_false(querychat_tool_starts_open("update")) + expect_false(querychat_tool_starts_open("reset")) +}) + +test_that("querychat_tool_starts_open is case-insensitive", { + withr::local_options(querychat.tool_details = "EXPANDED") + expect_true(querychat_tool_starts_open("query")) + + withr::local_options(querychat.tool_details = "Collapsed") + expect_false(querychat_tool_starts_open("query")) +}) + +test_that("querychat_tool_starts_open warns on invalid setting", { + withr::local_options(querychat.tool_details = "invalid") + + expect_warning( + querychat_tool_starts_open("query"), + "Invalid value for" + ) +}) + +test_that("option takes precedence over environment variable", { + withr::local_options(querychat.tool_details = "collapsed") + withr::local_envvar(QUERYCHAT_TOOL_DETAILS = "expanded") + + expect_false(querychat_tool_starts_open("query")) +})