diff --git a/js/build.mjs b/js/build.mjs index dda68cb85..6b95400d0 100644 --- a/js/build.mjs +++ b/js/build.mjs @@ -24,14 +24,6 @@ const jsTargets = [ source: "src/viz.ts", output: "../pkg-r/inst/htmldep/viz.js", }, - { - source: "src/schema-display.js", - output: "../pkg-py/src/querychat/static/js/schema-display.js", - }, - { - source: "src/schema-display.js", - output: "../pkg-r/inst/htmldep/schema-display.js", - }, ]; const cssTargets = [ diff --git a/js/src/schema-display.js b/js/src/schema-display.js deleted file mode 100644 index 95966542e..000000000 --- a/js/src/schema-display.js +++ /dev/null @@ -1,229 +0,0 @@ -let lastDisplay = null; -let lastDisplayTime = 0; -const BATCH_MS = 1000; -let activePanel = null; - -// -- Schema text parser -------------------------------------------------- - -function parseColumnsJson(json) { - return JSON.parse(json).map((col) => ({ - name: col.name, - type: col.sql_type, - units: col.units || null, - description: col.description || null, - constraints: col.constraints && col.constraints.length > 0 ? col.constraints.join(', ') : null, - range: - col.min_val != null && col.max_val != null ? `${col.min_val} to ${col.max_val}` : null, - categories: - col.categories && col.categories.length > 0 - ? col.categories.map((v) => `'${v}'`).join(', ') - : null, - })); -} - -// -- Table rendering ----------------------------------------------------- - -function esc(s) { - return String(s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -const TH = - 'padding:0.35em 0.75em;text-align:left;white-space:nowrap;font-weight:600;' + - 'border-bottom:2px solid var(--bs-border-color,#dee2e6);' + - 'background:var(--bs-tertiary-bg,#f8f9fa);' + - 'position:sticky;top:0;z-index:1;'; -const TD_MONO = - 'padding:0.3em 0.75em;white-space:nowrap;' + - 'font-family:var(--bs-font-monospace,monospace);font-size:0.875em;' + - 'border-bottom:1px solid var(--bs-border-color-translucent,rgba(0,0,0,.08));'; -const TD_WRAP = - 'padding:0.3em 0.75em;max-width:22em;overflow-wrap:break-word;' + - 'border-bottom:1px solid var(--bs-border-color-translucent,rgba(0,0,0,.08));'; -const TD_NOWRAP = - 'padding:0.3em 0.75em;white-space:nowrap;' + - 'border-bottom:1px solid var(--bs-border-color-translucent,rgba(0,0,0,.08));'; - -function renderTable(columns) { - const rows = columns - .map((col) => { - let typeCell = esc(col.type); - if (col.units) { - typeCell += ` [${esc(col.units)}]`; - } - const details = col.range - ? esc(col.range) - : col.categories - ? esc(col.categories) - : ''; - - return ( - `` + - `${esc(col.name)}` + - `${typeCell}` + - `${col.description ? esc(col.description) : ''}` + - `${col.constraints ? esc(col.constraints) : ''}` + - `${details}` + - `` - ); - }) - .join(''); - - return ( - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `${rows}` + - `
ColumnTypeDescriptionConstraintsRange / Values
` - ); -} - -// -- Panel positioning & lifecycle --------------------------------------- - -const PANEL_STYLE = - 'position:fixed;z-index:9999;' + - 'background:var(--bs-body-bg,#fff);color:var(--bs-body-color,#212529);' + - 'border:1px solid var(--bs-border-color,#dee2e6);' + - 'border-radius:var(--bs-border-radius,0.375rem);' + - 'box-shadow:0 4px 16px rgba(0,0,0,.15);' + - 'overflow:auto;' + - 'max-height:min(420px,60vh);'; - -function positionPanel(btn, panel) { - const rect = btn.getBoundingClientRect(); - const vw = window.innerWidth; - const vh = window.innerHeight; - - const pw = Math.min(Math.max(360, vw * 0.55), vw - 16); - panel.style.width = `${pw}px`; - panel.style.left = `${Math.max(8, Math.min(rect.left, vw - pw - 8))}px`; - - // Prefer below; fall back to above if there's more room there - const spaceBelow = vh - rect.bottom - 8; - const spaceAbove = rect.top - 8; - if (spaceBelow >= 120 || spaceBelow >= spaceAbove) { - panel.style.top = `${rect.bottom + 4}px`; - } else { - const panelH = Math.min(420, spaceAbove); - panel.style.top = `${Math.max(8, rect.top - panelH - 4)}px`; - } -} - -function closePanel() { - if (activePanel) { - activePanel.panel.hidden = true; - activePanel.btn.setAttribute('aria-expanded', 'false'); - activePanel = null; - } -} - -document.addEventListener('click', closePanel); -document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') closePanel(); -}); -window.addEventListener( - 'scroll', - (e) => { - if (activePanel && !activePanel.panel.contains(/** @type {Node} */ (e.target))) { - closePanel(); - } - }, - true, -); -window.addEventListener('resize', closePanel); - -// -- Button + panel construction ----------------------------------------- - -function createBtn(tableName, columnsJson) { - const columns = parseColumnsJson(columnsJson); - - const btn = document.createElement('button'); - btn.type = 'button'; - btn.style.cssText = - 'background:none;border:none;padding:0;color:inherit;' + - 'text-decoration:underline dotted;cursor:pointer;font-size:inherit;border-radius:2px;'; - btn.textContent = tableName; - btn.setAttribute('aria-label', `Show schema for ${tableName}`); - btn.setAttribute('aria-expanded', 'false'); - btn.setAttribute('aria-haspopup', 'dialog'); - - const panel = document.createElement('div'); - panel.setAttribute('role', 'dialog'); - panel.setAttribute('aria-label', `${tableName} schema`); - panel.style.cssText = PANEL_STYLE; - panel.hidden = true; - panel.innerHTML = renderTable(columns); - document.body.appendChild(panel); - - btn.addEventListener('click', (e) => { - e.stopPropagation(); - if (activePanel && activePanel.panel === panel) { - closePanel(); - return; - } - closePanel(); - positionPanel(btn, panel); - panel.hidden = false; - btn.setAttribute('aria-expanded', 'true'); - activePanel = { btn, panel }; - }); - - panel.addEventListener('click', (e) => e.stopPropagation()); - - return btn; -} - -// -- Focus ring for keyboard users (Bootstrap resets button outline) ----- - -const style = document.createElement('style'); -style.textContent = - '.qc-schema-display button:focus-visible{' + - 'outline:2px solid currentColor;outline-offset:2px;border-radius:2px}'; -document.head.appendChild(style); - -// -- MutationObserver --------------------------------------------------- - -function processCollector(sentinel) { - const now = Date.now(); - const tableName = sentinel.dataset.table; - const btn = createBtn(tableName, sentinel.dataset.schemaJson); - - if (lastDisplay && document.contains(lastDisplay) && now - lastDisplayTime < BATCH_MS) { - lastDisplay.appendChild(document.createTextNode(', ')); - lastDisplay.appendChild(btn); - sentinel.remove(); - } else { - const p = document.createElement('p'); - p.className = 'qc-schema-display'; - p.style.cssText = - 'color:var(--bs-secondary-color,#6c757d);font-size:0.875em;margin:0.1rem 0;'; - p.appendChild(document.createTextNode('🔍 Fetched schemas: ')); - p.appendChild(btn); - sentinel.replaceWith(p); - lastDisplay = p; - } - lastDisplayTime = now; -} - -new MutationObserver((mutations) => { - for (const { addedNodes } of mutations) { - for (const node of addedNodes) { - if (node.nodeType !== 1) continue; - if (/** @type {Element} */ (node).classList.contains('qc-schema-collector')) { - processCollector(/** @type {HTMLElement} */ (node)); - } else { - /** @type {Element} */ (node) - .querySelectorAll('.qc-schema-collector') - .forEach((el) => processCollector(/** @type {HTMLElement} */ (el))); - } - } - } -}).observe(document.body, { subtree: true, childList: true }); diff --git a/js/src/viz.css b/js/src/viz.css index 6c41e2246..a6af0000e 100644 --- a/js/src/viz.css +++ b/js/src/viz.css @@ -35,8 +35,8 @@ display: inline-flex; align-items: center; gap: 4px; - padding: 2px 8px; - height: 28px; + padding: 1px 0.25rem; + height: 24px; border: none; border-radius: var(--bs-border-radius, 4px); background: transparent; @@ -53,24 +53,36 @@ } .querychat-query-chevron { - font-size: 0.625rem; - transition: transform 150ms; - display: inline-block; + display: grid; + place-items: center; + width: 0.9em; + height: 0.9em; + flex: none; + opacity: var(--_activity-opacity-muted, 0.65); + transform: rotate(-90deg); + transition: transform 300ms ease-in-out; +} + +.querychat-query-chevron svg { + display: block; + width: 100%; + height: 100%; } .querychat-query-chevron--expanded { - transform: rotate(90deg); + transform: rotate(0); } .querychat-icon { - width: 14px; - height: 14px; + width: 1em; + height: 1em; } .querychat-dropdown-chevron { - width: 12px; - height: 12px; + width: 0.9em; + height: 0.9em; margin-left: 2px; + opacity: var(--_activity-opacity-muted, 0.65); } .querychat-save-dropdown { @@ -116,7 +128,8 @@ display: none; position: relative; border-top: 1px solid var(--bs-border-color, #dee2e6); - margin: 8px -16px -8px; + margin: 0.25rem calc(-1 * var(--_querychat-footer-pad-x)) + calc(-1 * var(--_querychat-footer-pad-y)); } .querychat-query-section--visible { @@ -128,10 +141,30 @@ .shiny-tool-card:has(.querychat-viz-container) { max-height: 700px; overflow: hidden; + opacity: 1; } -.shiny-tool-card:has(.querychat-viz-container) > .card-footer { +/* shinychat dims tool activity (opacity: .7); the framed viz group header + should stay fully opaque like the card body it introduces */ +.shiny-chat-tool-group--framed:has(.querychat-viz-container) + > .shiny-chat-tool-group__row { + opacity: 1; +} + +.shiny-tool-card:has(.querychat-viz-container) > .card-footer, +/* Match (and beat, via load order) the specificity of shinychat's framed + group footer rule, which would otherwise force card-cap padding */ +.shiny-chat-tool-group--framed + > .shiny-chat-tool-call-row__detail + > .shiny-tool-card:has(.querychat-viz-container) + > .card-footer { + --_querychat-footer-pad-y: 0.125rem; + --_querychat-footer-pad-x: calc(var(--_row-pad, 0.4rem) - 0.25rem); + flex: 0 0 auto; + padding: var(--_querychat-footer-pad-y) var(--_querychat-footer-pad-x); + border-top: var(--bs-border-width, 1px) solid + var(--bs-border-color, #dee2e6); } .shiny-tool-card[fullscreen]:has(.querychat-viz-container) { diff --git a/pkg-py/examples/10-viz-app.py b/pkg-py/examples/10-viz-app.py index 857e8421d..ba3ac3062 100644 --- a/pkg-py/examples/10-viz-app.py +++ b/pkg-py/examples/10-viz-app.py @@ -16,4 +16,4 @@ qc.ui() -ui.page_opts(fillable=True, title="QueryChat Visualization Demo") +ui.page_opts(fillable=True, title="QueryChat Visualization Demo") \ No newline at end of file diff --git a/pkg-py/src/querychat/_querychat_core.py b/pkg-py/src/querychat/_querychat_core.py index 59cae5347..0c33df3c4 100644 --- a/pkg-py/src/querychat/_querychat_core.py +++ b/pkg-py/src/querychat/_querychat_core.py @@ -101,8 +101,10 @@ def format_chunk(chunk: Union[str, Content]) -> str: def format_tool_result(result: ContentToolResult) -> str: """Extract displayable text from a tool result.""" display_info = result.extra.get("display") if result.extra else None - if display_info and hasattr(display_info, "markdown"): - return display_info.markdown + if display_info: + markdown = getattr(display_info, "markdown", None) + if markdown is not None: + return markdown if result.value is not None: return str(result.value) return "" diff --git a/pkg-py/src/querychat/_viz_tools.py b/pkg-py/src/querychat/_viz_tools.py index 531d90874..cebe81642 100644 --- a/pkg-py/src/querychat/_viz_tools.py +++ b/pkg-py/src/querychat/_viz_tools.py @@ -141,6 +141,7 @@ def __init__( full_screen=True, icon=bs_icon("graph-up"), footer=footer, + open_style="framed", ), } @@ -319,7 +320,7 @@ def build_viz_footer( "data-querychat-action": "show-query", "data-target": query_section_id, }, - tags.span({"class": "querychat-query-chevron"}, "\u25b6"), + bs_icon("chevron-down", cls="querychat-query-chevron"), tags.span({"class": "querychat-query-label"}, "Show Query"), ), ), diff --git a/pkg-py/src/querychat/static/css/viz.css b/pkg-py/src/querychat/static/css/viz.css index bbf54e6e6..0376c644f 100644 --- a/pkg-py/src/querychat/static/css/viz.css +++ b/pkg-py/src/querychat/static/css/viz.css @@ -36,8 +36,8 @@ display: inline-flex; align-items: center; gap: 4px; - padding: 2px 8px; - height: 28px; + padding: 1px 0.25rem; + height: 24px; border: none; border-radius: var(--bs-border-radius, 4px); background: transparent; @@ -54,24 +54,36 @@ } .querychat-query-chevron { - font-size: 0.625rem; - transition: transform 150ms; - display: inline-block; + display: grid; + place-items: center; + width: 0.9em; + height: 0.9em; + flex: none; + opacity: var(--_activity-opacity-muted, 0.65); + transform: rotate(-90deg); + transition: transform 300ms ease-in-out; +} + +.querychat-query-chevron svg { + display: block; + width: 100%; + height: 100%; } .querychat-query-chevron--expanded { - transform: rotate(90deg); + transform: rotate(0); } .querychat-icon { - width: 14px; - height: 14px; + width: 1em; + height: 1em; } .querychat-dropdown-chevron { - width: 12px; - height: 12px; + width: 0.9em; + height: 0.9em; margin-left: 2px; + opacity: var(--_activity-opacity-muted, 0.65); } .querychat-save-dropdown { @@ -117,7 +129,8 @@ display: none; position: relative; border-top: 1px solid var(--bs-border-color, #dee2e6); - margin: 8px -16px -8px; + margin: 0.25rem calc(-1 * var(--_querychat-footer-pad-x)) + calc(-1 * var(--_querychat-footer-pad-y)); } .querychat-query-section--visible { @@ -129,10 +142,30 @@ .shiny-tool-card:has(.querychat-viz-container) { max-height: 700px; overflow: hidden; + opacity: 1; } -.shiny-tool-card:has(.querychat-viz-container) > .card-footer { +/* shinychat dims tool activity (opacity: .7); the framed viz group header + should stay fully opaque like the card body it introduces */ +.shiny-chat-tool-group--framed:has(.querychat-viz-container) + > .shiny-chat-tool-group__row { + opacity: 1; +} + +.shiny-tool-card:has(.querychat-viz-container) > .card-footer, +/* Match (and beat, via load order) the specificity of shinychat's framed + group footer rule, which would otherwise force card-cap padding */ +.shiny-chat-tool-group--framed + > .shiny-chat-tool-call-row__detail + > .shiny-tool-card:has(.querychat-viz-container) + > .card-footer { + --_querychat-footer-pad-y: 0.125rem; + --_querychat-footer-pad-x: calc(var(--_row-pad, 0.4rem) - 0.25rem); + flex: 0 0 auto; + padding: var(--_querychat-footer-pad-y) var(--_querychat-footer-pad-x); + border-top: var(--bs-border-width, 1px) solid + var(--bs-border-color, #dee2e6); } .shiny-tool-card[fullscreen]:has(.querychat-viz-container) { diff --git a/pkg-py/src/querychat/static/js/schema-display.js b/pkg-py/src/querychat/static/js/schema-display.js deleted file mode 100644 index b21376c6c..000000000 --- a/pkg-py/src/querychat/static/js/schema-display.js +++ /dev/null @@ -1,154 +0,0 @@ -/* Generated file. Source: js/src/schema-display.js. Do not edit directly. */ - -"use strict"; -(() => { - // src/schema-display.js - var lastDisplay = null; - var lastDisplayTime = 0; - var BATCH_MS = 1e3; - var activePanel = null; - function parseColumnsJson(json) { - return JSON.parse(json).map((col) => ({ - name: col.name, - type: col.sql_type, - units: col.units || null, - description: col.description || null, - constraints: col.constraints && col.constraints.length > 0 ? col.constraints.join(", ") : null, - range: col.min_val != null && col.max_val != null ? `${col.min_val} to ${col.max_val}` : null, - categories: col.categories && col.categories.length > 0 ? col.categories.map((v) => `'${v}'`).join(", ") : null - })); - } - function esc(s) { - return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); - } - var TH = "padding:0.35em 0.75em;text-align:left;white-space:nowrap;font-weight:600;border-bottom:2px solid var(--bs-border-color,#dee2e6);background:var(--bs-tertiary-bg,#f8f9fa);position:sticky;top:0;z-index:1;"; - var TD_MONO = "padding:0.3em 0.75em;white-space:nowrap;font-family:var(--bs-font-monospace,monospace);font-size:0.875em;border-bottom:1px solid var(--bs-border-color-translucent,rgba(0,0,0,.08));"; - var TD_WRAP = "padding:0.3em 0.75em;max-width:22em;overflow-wrap:break-word;border-bottom:1px solid var(--bs-border-color-translucent,rgba(0,0,0,.08));"; - var TD_NOWRAP = "padding:0.3em 0.75em;white-space:nowrap;border-bottom:1px solid var(--bs-border-color-translucent,rgba(0,0,0,.08));"; - function renderTable(columns) { - const rows = columns.map((col) => { - let typeCell = esc(col.type); - if (col.units) { - typeCell += ` [${esc(col.units)}]`; - } - const details = col.range ? esc(col.range) : col.categories ? esc(col.categories) : ""; - return `${esc(col.name)}${typeCell}${col.description ? esc(col.description) : ""}${col.constraints ? esc(col.constraints) : ""}${details}`; - }).join(""); - return `${rows}
ColumnTypeDescriptionConstraintsRange / Values
`; - } - var PANEL_STYLE = "position:fixed;z-index:9999;background:var(--bs-body-bg,#fff);color:var(--bs-body-color,#212529);border:1px solid var(--bs-border-color,#dee2e6);border-radius:var(--bs-border-radius,0.375rem);box-shadow:0 4px 16px rgba(0,0,0,.15);overflow:auto;max-height:min(420px,60vh);"; - function positionPanel(btn, panel) { - const rect = btn.getBoundingClientRect(); - const vw = window.innerWidth; - const vh = window.innerHeight; - const pw = Math.min(Math.max(360, vw * 0.55), vw - 16); - panel.style.width = `${pw}px`; - panel.style.left = `${Math.max(8, Math.min(rect.left, vw - pw - 8))}px`; - const spaceBelow = vh - rect.bottom - 8; - const spaceAbove = rect.top - 8; - if (spaceBelow >= 120 || spaceBelow >= spaceAbove) { - panel.style.top = `${rect.bottom + 4}px`; - } else { - const panelH = Math.min(420, spaceAbove); - panel.style.top = `${Math.max(8, rect.top - panelH - 4)}px`; - } - } - function closePanel() { - if (activePanel) { - activePanel.panel.hidden = true; - activePanel.btn.setAttribute("aria-expanded", "false"); - activePanel = null; - } - } - document.addEventListener("click", closePanel); - document.addEventListener("keydown", (e) => { - if (e.key === "Escape") closePanel(); - }); - window.addEventListener( - "scroll", - (e) => { - if (activePanel && !activePanel.panel.contains( - /** @type {Node} */ - e.target - )) { - closePanel(); - } - }, - true - ); - window.addEventListener("resize", closePanel); - function createBtn(tableName, columnsJson) { - const columns = parseColumnsJson(columnsJson); - const btn = document.createElement("button"); - btn.type = "button"; - btn.style.cssText = "background:none;border:none;padding:0;color:inherit;text-decoration:underline dotted;cursor:pointer;font-size:inherit;border-radius:2px;"; - btn.textContent = tableName; - btn.setAttribute("aria-label", `Show schema for ${tableName}`); - btn.setAttribute("aria-expanded", "false"); - btn.setAttribute("aria-haspopup", "dialog"); - const panel = document.createElement("div"); - panel.setAttribute("role", "dialog"); - panel.setAttribute("aria-label", `${tableName} schema`); - panel.style.cssText = PANEL_STYLE; - panel.hidden = true; - panel.innerHTML = renderTable(columns); - document.body.appendChild(panel); - btn.addEventListener("click", (e) => { - e.stopPropagation(); - if (activePanel && activePanel.panel === panel) { - closePanel(); - return; - } - closePanel(); - positionPanel(btn, panel); - panel.hidden = false; - btn.setAttribute("aria-expanded", "true"); - activePanel = { btn, panel }; - }); - panel.addEventListener("click", (e) => e.stopPropagation()); - return btn; - } - var style = document.createElement("style"); - style.textContent = ".qc-schema-display button:focus-visible{outline:2px solid currentColor;outline-offset:2px;border-radius:2px}"; - document.head.appendChild(style); - function processCollector(sentinel) { - const now = Date.now(); - const tableName = sentinel.dataset.table; - const btn = createBtn(tableName, sentinel.dataset.schemaJson); - if (lastDisplay && document.contains(lastDisplay) && now - lastDisplayTime < BATCH_MS) { - lastDisplay.appendChild(document.createTextNode(", ")); - lastDisplay.appendChild(btn); - sentinel.remove(); - } else { - const p = document.createElement("p"); - p.className = "qc-schema-display"; - p.style.cssText = "color:var(--bs-secondary-color,#6c757d);font-size:0.875em;margin:0.1rem 0;"; - p.appendChild(document.createTextNode("\u{1F50D} Fetched schemas: ")); - p.appendChild(btn); - sentinel.replaceWith(p); - lastDisplay = p; - } - lastDisplayTime = now; - } - new MutationObserver((mutations) => { - for (const { addedNodes } of mutations) { - for (const node of addedNodes) { - if (node.nodeType !== 1) continue; - if ( - /** @type {Element} */ - node.classList.contains("qc-schema-collector") - ) { - processCollector( - /** @type {HTMLElement} */ - node - ); - } else { - node.querySelectorAll(".qc-schema-collector").forEach((el) => processCollector( - /** @type {HTMLElement} */ - el - )); - } - } - } - }).observe(document.body, { subtree: true, childList: true }); -})(); diff --git a/pkg-py/src/querychat/tools.py b/pkg-py/src/querychat/tools.py index 9ef9a81c9..2abb1cd79 100644 --- a/pkg-py/src/querychat/tools.py +++ b/pkg-py/src/querychat/tools.py @@ -1,17 +1,14 @@ from __future__ import annotations import html -import json from collections.abc import Callable from typing import TYPE_CHECKING, Any, Protocol, TypedDict, runtime_checkable -from chatlas import ContentToolRequest, ContentToolResult, Tool -from htmltools import HTMLDependency, TagList, tags +from chatlas import ContentToolResult, Tool +from htmltools import Tag, tags from pydantic import Field -from shinychat import message_content_chunk -from shinychat.types import ChatMessage, ToolResultDisplay +from shinychat.types import ToolResultDisplay -from .__version import __version__ from ._datasource import ColumnMeta, format_schema from ._icons import bs_icon from ._utils import ( @@ -47,54 +44,6 @@ class GetSchemaResult(ContentToolResult): columns: list[ColumnMeta] = Field(default_factory=list) -def _col_to_dict(col: ColumnMeta) -> dict[str, Any]: - return { - "name": col.name, - "sql_type": col.sql_type, - "units": col.units, - "description": col.description, - "min_val": str(col.min_val) if col.min_val is not None else None, - "max_val": str(col.max_val) if col.max_val is not None else None, - "categories": col.categories, - "constraints": col.constraints, - } - - -_orig_request_handler = message_content_chunk.dispatch(ContentToolRequest) - - -@message_content_chunk.register -def _(request: ContentToolRequest) -> ChatMessage: - if request.name == "querychat_get_schema": - return ChatMessage(content="") - return _orig_request_handler(request) - - -@message_content_chunk.register -def _(message: GetSchemaResult) -> ChatMessage: - columns_json = json.dumps([_col_to_dict(c) for c in message.columns]) - content = TagList( - tags.span( - class_="qc-schema-collector", - data_table=message.table_name, - data_schema=str(message.value), - data_schema_json=columns_json, - style="display:none", - ), - _schema_dep(), - ) - return ChatMessage(content=content) - - -def _schema_dep() -> HTMLDependency: - return HTMLDependency( - "querychat-schema-display", - __version__, - source={"package": "querychat", "subdir": "static"}, - script=[{"src": "js/schema-display.js"}], - ) - - def _get_schema_impl( data_dicts: list[DataDict], executor: QueryExecutor, @@ -115,7 +64,19 @@ def get_schema(table_name: str) -> ContentToolResult: schema_text = format_schema(table_name, columns) return GetSchemaResult( - value=schema_text, table_name=table_name, columns=columns + value=schema_text, + table_name=table_name, + columns=columns, + extra={ + "display": ToolResultDisplay( + label=table_name, + value_preview=f"{len(columns)} column" + f"{'' if len(columns) == 1 else 's'}", + html=_schema_table(columns), + show_request=False, + open=False, + ) + }, ) return get_schema @@ -156,7 +117,52 @@ def tool_get_schema( return Tool.from_func( impl, name="querychat_get_schema", - annotations={"title": "Get Schema"}, + annotations={"title": "Fetch schemas"}, + ) + + +def _schema_table(columns: list[ColumnMeta]) -> Tag: + headers = ("Column", "Type", "Range / Values", "Description", "Constraints") + rows: list[Tag] = [] + + for column in columns: + type_cell = tags.span(column.sql_type) + if column.units: + type_cell.append( + " ", + tags.span(column.units, class_="text-body-secondary"), + ) + + range_values = "" + if ( + column.kind in ("numeric", "date") + and column.min_val is not None + and column.max_val is not None + ): + range_values = f"{column.min_val} to {column.max_val}" + elif column.categories: + range_values = ", ".join(f"'{value}'" for value in column.categories) + + rows.append( + tags.tr( + tags.th(tags.code(column.name), scope="row"), + tags.td(type_cell), + tags.td(range_values), + tags.td(column.description or ""), + tags.td(", ".join(column.constraints)), + ) + ) + + return tags.div( + tags.table( + tags.thead( + tags.tr(*(tags.th(header, scope="col") for header in headers)), + class_="table-light", + ), + tags.tbody(*rows), + class_="table table-sm table-hover align-middle mb-0", + ), + class_="table-responsive", ) diff --git a/pkg-py/tests/playwright/test_11_viz_footer.py b/pkg-py/tests/playwright/test_11_viz_footer.py index 408f7f8b9..59cb663c6 100644 --- a/pkg-py/tests/playwright/test_11_viz_footer.py +++ b/pkg-py/tests/playwright/test_11_viz_footer.py @@ -9,6 +9,7 @@ from __future__ import annotations +import re import time from pathlib import Path from typing import TYPE_CHECKING @@ -118,6 +119,63 @@ def _send_viz_prompt( _wait_for_stable_position(page.locator(".querychat-show-query-btn")) +class TestVizFrame: + def test_expanded_visualization_has_complete_frame(self, page: Page) -> None: + # Filter structurally, not by the LLM-chosen chart title + group = page.locator( + ".shiny-chat-tool-group--single:has(.querychat-viz-container)" + ) + card = page.locator(".shiny-tool-card:has(.querychat-viz-container)") + summary = group.locator(":scope > .shiny-chat-tool-group__row") + footer = card.locator(":scope > .card-footer") + + expect(card).to_have_css("opacity", "1") + expect(summary).to_have_css("opacity", "1") + for side in ("top", "right", "bottom", "left"): + expect(group).to_have_css(f"border-{side}-width", "1px") + expect(card).to_have_css(f"border-{side}-width", "0px") + expect(summary).to_have_css("border-bottom-width", "1px") + expect(summary).to_have_css("border-radius", "0px") + expect(footer).to_have_css("border-top-width", "1px") + expect(footer).to_have_css("padding-top", "2px") + expect(footer).to_have_css("padding-bottom", "2px") + + header_box = summary.bounding_box() + footer_box = footer.bounding_box() + header_glyph_box = summary.locator( + ".shiny-chat-tool-group__glyph" + ).bounding_box() + header_chevron_box = summary.locator( + ".shiny-chat-tool-group__chevron" + ).bounding_box() + show_chevron_box = footer.locator(".querychat-query-chevron").bounding_box() + save_chevron_box = footer.locator(".querychat-dropdown-chevron").bounding_box() + + assert header_box is not None + assert footer_box is not None + assert header_glyph_box is not None + assert header_chevron_box is not None + assert show_chevron_box is not None + assert save_chevron_box is not None + assert abs(header_box["height"] - footer_box["height"]) <= 1 + assert abs(header_glyph_box["x"] - show_chevron_box["x"]) <= 0.5 + assert ( + abs( + (header_chevron_box["x"] + header_chevron_box["width"]) + - (save_chevron_box["x"] + save_chevron_box["width"]) + ) + <= 0.5 + ) + + summary.click() + # Collapsing unmounts the card (and the viz container with it), so + # the :has() locator above no longer matches. The viz group is the + # last tool group in the chat. + collapsed_group = page.locator(".shiny-chat-tool-group--single").last + for side in ("top", "right", "bottom", "left"): + expect(collapsed_group).to_have_css(f"border-{side}-width", "0px") + + class TestShowQueryToggle: """Tests for the Show Query / Hide Query toggle button.""" @@ -152,7 +210,7 @@ def test_chevron_rotates_on_expand(self, page: Page) -> None: expect(chevron).not_to_have_class("querychat-query-chevron--expanded") btn.click() expect(chevron).to_have_class( - "querychat-query-chevron querychat-query-chevron--expanded" + re.compile(r"\bquerychat-query-chevron--expanded\b") ) def test_toggle_hides_section_again(self, page: Page) -> None: diff --git a/pkg-py/tests/test_querychat_core.py b/pkg-py/tests/test_querychat_core.py new file mode 100644 index 000000000..2d6ca63d5 --- /dev/null +++ b/pkg-py/tests/test_querychat_core.py @@ -0,0 +1,50 @@ +"""Tests for querychat._querychat_core formatting helpers.""" + +import pytest +from chatlas import ContentToolResult +from querychat._querychat_core import format_chunk, format_tool_result +from shinychat.types import ToolResultDisplay + + +def test_format_tool_result_without_display_returns_value(): + result = ContentToolResult(value="schema text") + assert format_tool_result(result) == "schema text" + + +def test_format_tool_result_with_markdown_display_returns_markdown(): + result = ContentToolResult( + value="schema text", + extra={"display": ToolResultDisplay(markdown="**schema**")}, + ) + assert format_tool_result(result) == "**schema**" + + +def test_format_tool_result_with_none_markdown_falls_back_to_value(): + # ToolResultDisplay.markdown defaults to None; the display's mere + # existence must not shadow the result value. + result = ContentToolResult( + value="schema text", + extra={"display": ToolResultDisplay(label="titanic")}, + ) + assert format_tool_result(result) == "schema text" + + +def test_format_tool_result_without_value_returns_empty_string(): + result = ContentToolResult( + value=None, + extra={"display": ToolResultDisplay(label="titanic")}, + ) + assert format_tool_result(result) == "" + + +def test_format_chunk_wraps_tool_result_without_crashing(): + result = ContentToolResult( + value="schema text", + extra={"display": ToolResultDisplay(label="titanic")}, + ) + assert format_chunk(result) == "\n\nschema text\n\n" + + +def test_format_chunk_rejects_unknown_type(): + with pytest.raises(ValueError, match="Unknown chunk type"): + format_chunk(42) # type: ignore[arg-type] diff --git a/pkg-py/tests/test_tools.py b/pkg-py/tests/test_tools.py index e7d631477..0b0c64a4d 100644 --- a/pkg-py/tests/test_tools.py +++ b/pkg-py/tests/test_tools.py @@ -1,15 +1,15 @@ """Tests for tool functions and utilities.""" -import html as html_module +import re import warnings import narwhals.stable.v1 as nw import pandas as pd import polars as pl import pytest -from htmltools import TagList +from htmltools import Tag from querychat._data_dict import ColumnRange, ColumnSpec, DataDict, TableSpec -from querychat._datasource import DataFrameSource +from querychat._datasource import ColumnMeta, DataFrameSource from querychat._query_executor import DataSourceExecutor from querychat._utils import querychat_tool_starts_open from querychat.tools import ( @@ -17,9 +17,9 @@ UpdateDashboardData, _get_schema_impl, _query_impl, + tool_get_schema, tool_reset_dashboard, ) -from shinychat import message_content_chunk @pytest.fixture @@ -191,20 +191,83 @@ def _make_executor_and_table( return executor, [table_name] -def test_get_schema_impl_with_data_dict() -> None: +def test_get_schema_tool_preserves_metadata_for_model_and_display() -> None: dd = DataDict( tables={ "orders": TableSpec( - columns=[ColumnSpec(name="amount", range=ColumnRange(min=0, max=100))] + columns=[ + ColumnSpec( + name="amount", + description="Gross & tax", + units="USD", + constraints=[">= 0", "required"], + range=ColumnRange(min=0, max=100), + ), + ColumnSpec( + name="status", + values=["pending", "shipped & paid"], + ), + ] ) }, ) - df = pl.DataFrame({"amount": [10, 20]}) + df = pl.DataFrame( + { + "amount": [10, 20], + "status": ["pending", "shipped & paid"], + } + ) executor, table_names = _make_executor_and_table(df, "orders") fn = _get_schema_impl([dd], executor, table_names, categorical_threshold=10) + result = fn("orders") - assert "amount" in str(result.value) - assert "Range: 0 to 100" in str(result.value) + + assert isinstance(result, GetSchemaResult) + assert result.value == ( + "Table: orders\n" + "Columns:\n" + "- amount (INTEGER) [USD]\n" + " Description: Gross & tax\n" + " Constraints: >= 0, required\n" + " Range: 0 to 100\n" + "- status (TEXT)\n" + " Categorical values: 'pending', 'shipped & paid'" + ) + assert all(isinstance(column, ColumnMeta) for column in result.columns) + assert [column.name for column in result.columns] == ["amount", "status"] + + assert result.extra is not None + display = result.extra["display"] + assert isinstance(display.html, Tag) + rendered = display.html.render()["html"] + expected_headers = ( + "Column", + "Type", + "Range / Values", + "Description", + "Constraints", + ) + header_positions = [ + rendered.index(f'{header}') + for header in expected_headers + ] + amount_cell_positions = [ + rendered.index(content) + for content in ( + "amount", + "INTEGER ", + "0 to 100", + "Gross <amount> & tax", + ">= 0, required", + ) + ] + assert header_positions == sorted(header_positions) + assert amount_cell_positions == sorted(amount_cell_positions) + assert "Gross <amount> & tax" in rendered + assert 'USD' in rendered + assert ">= 0, required" in rendered + assert "0 to 100" in rendered + assert "'pending', 'shipped & paid'" in rendered def test_get_schema_impl_without_data_dict() -> None: @@ -224,31 +287,45 @@ def test_get_schema_impl_unknown_table_returns_error() -> None: assert "nonexistent" in str(result.error) -def test_get_schema_result_sentinel_has_data_attributes(): - result = GetSchemaResult( - value="Table: orders\nColumns:\n- id (INTEGER)", - table_name="orders", +def test_get_schema_tool_uses_native_display() -> None: + df = pl.DataFrame( + { + "order_id": [1, 2], + "status": ["pending", "shipped"], + } ) - msg = message_content_chunk(result) - # msg.content is a pre-rendered HTML string; wrap in TagList to render again - rendered = TagList(msg.content).render() - html = rendered["html"] - assert "qc-schema-collector" in html - assert 'data-table="orders"' in html - assert "display:none" in html - - -def test_get_schema_result_sentinel_embeds_schema(): - schema = "Table: orders\nColumns:\n- id (INTEGER)" - result = GetSchemaResult(value=schema, table_name="orders") - msg = message_content_chunk(result) - # ChatMessage pre-renders TagList to a string; unescape HTML entities to check schema - assert schema in html_module.unescape(msg.content) - - -def test_get_schema_result_includes_js_dependency(): - result = GetSchemaResult(value="Table: t\nColumns:\n- x (TEXT)", table_name="t") - msg = message_content_chunk(result) - # ChatMessage extracts HTMLDependency objects into html_deps - dep_names = [d.name for d in msg.html_deps] - assert "querychat-schema-display" in dep_names + executor, table_names = _make_executor_and_table(df, "orders") + tool = tool_get_schema([], executor, table_names, categorical_threshold=10) + + result = tool.func(table_name="orders") + display = result.extra["display"] + + assert tool.annotations is not None + assert tool.annotations["title"] == "Fetch schemas" + assert display.label == "orders" + assert display.value_preview == "2 columns" + assert display.show_request is False + assert display.open is False + assert isinstance(display.html, Tag) + + html = display.html.render()["html"] + assert '
' in html + assert '' in html + assert '' in html + for header in ("Column", "Type", "Range / Values", "Description", "Constraints"): + assert f'' in html + assert re.search( + r'', + html, + ) + assert "INTEGER" in html + + +def test_get_schema_tool_uses_singular_column_preview() -> None: + df = pl.DataFrame({"order_id": [1]}) + executor, table_names = _make_executor_and_table(df, "orders") + tool = tool_get_schema([], executor, table_names, categorical_threshold=10) + + result = tool.func(table_name="orders") + + assert result.extra["display"].value_preview == "1 column" diff --git a/pkg-py/tests/test_viz_footer.py b/pkg-py/tests/test_viz_footer.py index 1943fcdb1..df93c81bb 100644 --- a/pkg-py/tests/test_viz_footer.py +++ b/pkg-py/tests/test_viz_footer.py @@ -6,6 +6,8 @@ shinychat renders this in the card footer area. """ +import re +from pathlib import Path from unittest.mock import MagicMock import narwhals.stable.v1 as nw @@ -14,6 +16,8 @@ from htmltools import TagList, tags from querychat._datasource import DataFrameSource +VIZ_CSS_PATH = Path(__file__).parents[2] / "js" / "src" / "viz.css" + @pytest.fixture def sample_df(): @@ -86,6 +90,21 @@ def test_cls_parameter_injects_class(self): html = str(bs_icon("download", cls="querychat-icon")) assert "querychat-icon" in html + def test_show_query_uses_shinychat_disclosure_chevron(self): + from querychat._viz_tools import build_viz_footer + + rendered = TagList( + build_viz_footer( + "SELECT * FROM test_data VISUALISE x, y DRAW point", + "Chart", + dom_widget_id="querychat_viz_raw", + ) + ).render()["html"] + + assert "querychat-query-chevron" in rendered + assert "bi-chevron-down" in rendered + assert "\u25b6" not in rendered + class TestVizPreloadMarkup: def test_preload_markup_has_no_inline_script(self): @@ -119,3 +138,53 @@ def test_build_viz_footer_uses_resolved_dom_widget_id(self): assert 'data-widget-id="module-querychat_viz_raw"' in rendered assert 'data-widget-id="querychat_viz_raw"' not in rendered + + +class TestVizFrameStyles: + def test_visualization_card_is_opaque_and_footer_is_compact(self): + css = VIZ_CSS_PATH.read_text(encoding="utf-8") + + card = re.search( + r"\.shiny-tool-card:has\(\.querychat-viz-container\)\s*" + r"\{(?P[^}]*)\}", + css, + ) + assert card is not None + assert "opacity: 1;" in card["declarations"] + + footer = re.search( + r"\.shiny-tool-card:has\(\.querychat-viz-container\)" + r"\s*> \.card-footer\s*\{(?P[^}]*)\}", + css, + ) + assert footer is not None + assert "--_querychat-footer-pad-y: 0.125rem;" in footer["declarations"] + assert ( + "--_querychat-footer-pad-x: " + "calc(var(--_row-pad, 0.4rem) - 0.25rem);" + in footer["declarations"] + ) + assert ( + "padding: var(--_querychat-footer-pad-y) " + "var(--_querychat-footer-pad-x);" + in footer["declarations"] + ) + + buttons = re.search( + r"\.querychat-show-query-btn,\s*" + r"\.querychat-save-btn\s*\{(?P[^}]*)\}", + css, + ) + assert buttons is not None + assert "height: 24px;" in buttons["declarations"] + assert "padding: 1px 0.25rem;" in buttons["declarations"] + + def test_visualization_requests_shinychat_framed_open_style(self): + source = ( + Path(__file__).parents[1] + / "src" + / "querychat" + / "_viz_tools.py" + ).read_text(encoding="utf-8") + + assert 'open_style="framed"' in source diff --git a/pkg-r/NAMESPACE b/pkg-r/NAMESPACE index 4361f3516..e5f9a84c4 100644 --- a/pkg-r/NAMESPACE +++ b/pkg-r/NAMESPACE @@ -20,4 +20,3 @@ import(rlang) importFrom(R6,R6Class) importFrom(bslib,sidebar) importFrom(lifecycle,deprecated) -importFrom(shinychat,contents_shinychat) diff --git a/pkg-r/R/querychat-package.R b/pkg-r/R/querychat-package.R index e5a51df40..cc9656341 100644 --- a/pkg-r/R/querychat-package.R +++ b/pkg-r/R/querychat-package.R @@ -76,10 +76,6 @@ NULL #' @rawNamespace if (getRversion() < "4.3.0") importFrom("S7", "@") NULL -.onLoad <- function(libname, pkgname) { - rlang::run_on_load() -} - release_bullets <- function() { c( "Run `staticimports::import()` to update static imports", diff --git a/pkg-r/R/querychat_tools.R b/pkg-r/R/querychat_tools.R index d4316c749..ed59cd53f 100644 --- a/pkg-r/R/querychat_tools.R +++ b/pkg-r/R/querychat_tools.R @@ -3,29 +3,10 @@ GetSchemaResult <- S7::new_class( "GetSchemaResult", parent = ellmer::ContentToolResult, properties = list( - table_name = S7::class_character, - columns_json = S7::new_property(S7::class_character, default = "") + table_name = S7::class_character ) ) -#' @importFrom shinychat contents_shinychat -rlang::on_load({ - S7::method(contents_shinychat, GetSchemaResult) <- get_schema_result_display - - orig_request_contents <- S7::method( - contents_shinychat, - ellmer::ContentToolRequest - ) - S7::method(contents_shinychat, ellmer::ContentToolRequest) <- function( - content - ) { - if (identical(content@name, "querychat_get_schema")) { - return(NULL) - } - orig_request_contents(content) - } -}) - tool_get_schema <- function( data_dicts, executor, @@ -52,11 +33,22 @@ tool_get_schema <- function( categorical_threshold, table_spec = table_spec ) - columns_json <- jsonlite::toJSON(schema_result$columns, auto_unbox = TRUE) + column_count <- length(schema_result$columns) GetSchemaResult( value = schema_result$text, table_name = table_name, - columns_json = as.character(columns_json) + extra = list( + display = shinychat::tool_result_display( + label = table_name, + value_preview = paste( + column_count, + if (column_count == 1) "column" else "columns" + ), + html = schema_table(schema_result$columns), + show_request = FALSE, + open = FALSE + ) + ) ) }, name = "querychat_get_schema", @@ -66,7 +58,7 @@ tool_get_schema <- function( "The name of the table to retrieve schema for." ) ), - annotations = ellmer::tool_annotations(title = "Get Schema") + annotations = ellmer::tool_annotations(title = "Fetch schemas") ) } @@ -364,25 +356,82 @@ querychat_tool_result <- function( ) } -schema_dep <- function() { - htmltools::htmlDependency( - name = "querychat-schema-display", - version = utils::packageVersion("querychat"), - package = "querychat", - src = "htmldep", - script = "schema-display.js" +schema_table <- function(columns) { + headers <- c( + "Column", + "Type", + "Range / Values", + "Description", + "Constraints" ) -} + rows <- lapply(columns, function(column) { + units <- schema_scalar_text(column$units) + type_cell <- htmltools::tagList( + htmltools::tags$span(schema_scalar_text(column$sql_type)), + if (nzchar(units)) { + htmltools::tagList( + " ", + htmltools::tags$span(units, class = "text-body-secondary") + ) + } + ) + + constraints <- vapply( + column$constraints, + schema_scalar_text, + character(1) + ) + constraints <- paste(constraints[nzchar(constraints)], collapse = ", ") + + categories <- vapply( + column$categories, + schema_scalar_text, + character(1) + ) + categories <- categories[nzchar(categories)] + min_val <- schema_scalar_text(column$min_val) + max_val <- schema_scalar_text(column$max_val) + range_values <- if (nzchar(min_val) && nzchar(max_val)) { + paste(min_val, "to", max_val) + } else if (length(categories) > 0) { + paste0("'", categories, "'", collapse = ", ") + } else { + "" + } -get_schema_result_display <- function(content) { - htmltools::tagList( - htmltools::tags$span( - class = "qc-schema-collector", - `data-table` = content@table_name, - `data-schema` = content@value, - `data-schema-json` = content@columns_json, - style = "display:none" + htmltools::tags$tr( + htmltools::tags$th( + htmltools::tags$code(schema_scalar_text(column$name)), + scope = "row" + ), + htmltools::tags$td(type_cell), + htmltools::tags$td(range_values), + htmltools::tags$td(schema_scalar_text(column$description)), + htmltools::tags$td(constraints) + ) + }) + + htmltools::tags$div( + htmltools::tags$table( + htmltools::tags$thead( + htmltools::tags$tr( + lapply(headers, function(header) { + htmltools::tags$th(header, scope = "col") + }) + ), + class = "table-light" + ), + htmltools::tags$tbody(rows), + class = "table table-sm table-hover align-middle mb-0" ), - schema_dep() + class = "table-responsive" ) } + +schema_scalar_text <- function(value) { + if (is.null(value) || length(value) == 0 || is.na(value[[1]])) { + return("") + } + + as.character(value[[1]]) +} diff --git a/pkg-r/R/querychat_viz.R b/pkg-r/R/querychat_viz.R index c84d15bdc..27bbb10dc 100644 --- a/pkg-r/R/querychat_viz.R +++ b/pkg-r/R/querychat_viz.R @@ -153,7 +153,8 @@ visualize_result <- function( open = querychat_tool_starts_open("visualize"), full_screen = TRUE, icon = viz_icon(), - footer = freeze_tags(footer) + footer = freeze_tags(footer), + open_style = "framed" ) ) @@ -242,7 +243,7 @@ build_viz_footer <- function( class = "querychat-show-query-btn", `data-querychat-action` = "show-query", `data-target` = query_section_id, - shiny::tags$span(class = "querychat-query-chevron", "\u25b6"), + bsicons::bs_icon("chevron-down", class = "querychat-query-chevron"), shiny::tags$span(class = "querychat-query-label", "Show Query") ) ), diff --git a/pkg-r/inst/htmldep/schema-display.js b/pkg-r/inst/htmldep/schema-display.js deleted file mode 100644 index b21376c6c..000000000 --- a/pkg-r/inst/htmldep/schema-display.js +++ /dev/null @@ -1,154 +0,0 @@ -/* Generated file. Source: js/src/schema-display.js. Do not edit directly. */ - -"use strict"; -(() => { - // src/schema-display.js - var lastDisplay = null; - var lastDisplayTime = 0; - var BATCH_MS = 1e3; - var activePanel = null; - function parseColumnsJson(json) { - return JSON.parse(json).map((col) => ({ - name: col.name, - type: col.sql_type, - units: col.units || null, - description: col.description || null, - constraints: col.constraints && col.constraints.length > 0 ? col.constraints.join(", ") : null, - range: col.min_val != null && col.max_val != null ? `${col.min_val} to ${col.max_val}` : null, - categories: col.categories && col.categories.length > 0 ? col.categories.map((v) => `'${v}'`).join(", ") : null - })); - } - function esc(s) { - return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); - } - var TH = "padding:0.35em 0.75em;text-align:left;white-space:nowrap;font-weight:600;border-bottom:2px solid var(--bs-border-color,#dee2e6);background:var(--bs-tertiary-bg,#f8f9fa);position:sticky;top:0;z-index:1;"; - var TD_MONO = "padding:0.3em 0.75em;white-space:nowrap;font-family:var(--bs-font-monospace,monospace);font-size:0.875em;border-bottom:1px solid var(--bs-border-color-translucent,rgba(0,0,0,.08));"; - var TD_WRAP = "padding:0.3em 0.75em;max-width:22em;overflow-wrap:break-word;border-bottom:1px solid var(--bs-border-color-translucent,rgba(0,0,0,.08));"; - var TD_NOWRAP = "padding:0.3em 0.75em;white-space:nowrap;border-bottom:1px solid var(--bs-border-color-translucent,rgba(0,0,0,.08));"; - function renderTable(columns) { - const rows = columns.map((col) => { - let typeCell = esc(col.type); - if (col.units) { - typeCell += ` [${esc(col.units)}]`; - } - const details = col.range ? esc(col.range) : col.categories ? esc(col.categories) : ""; - return ``; - }).join(""); - return `
{header}\s*order_id\s*
${esc(col.name)}${typeCell}${col.description ? esc(col.description) : ""}${col.constraints ? esc(col.constraints) : ""}${details}
${rows}
ColumnTypeDescriptionConstraintsRange / Values
`; - } - var PANEL_STYLE = "position:fixed;z-index:9999;background:var(--bs-body-bg,#fff);color:var(--bs-body-color,#212529);border:1px solid var(--bs-border-color,#dee2e6);border-radius:var(--bs-border-radius,0.375rem);box-shadow:0 4px 16px rgba(0,0,0,.15);overflow:auto;max-height:min(420px,60vh);"; - function positionPanel(btn, panel) { - const rect = btn.getBoundingClientRect(); - const vw = window.innerWidth; - const vh = window.innerHeight; - const pw = Math.min(Math.max(360, vw * 0.55), vw - 16); - panel.style.width = `${pw}px`; - panel.style.left = `${Math.max(8, Math.min(rect.left, vw - pw - 8))}px`; - const spaceBelow = vh - rect.bottom - 8; - const spaceAbove = rect.top - 8; - if (spaceBelow >= 120 || spaceBelow >= spaceAbove) { - panel.style.top = `${rect.bottom + 4}px`; - } else { - const panelH = Math.min(420, spaceAbove); - panel.style.top = `${Math.max(8, rect.top - panelH - 4)}px`; - } - } - function closePanel() { - if (activePanel) { - activePanel.panel.hidden = true; - activePanel.btn.setAttribute("aria-expanded", "false"); - activePanel = null; - } - } - document.addEventListener("click", closePanel); - document.addEventListener("keydown", (e) => { - if (e.key === "Escape") closePanel(); - }); - window.addEventListener( - "scroll", - (e) => { - if (activePanel && !activePanel.panel.contains( - /** @type {Node} */ - e.target - )) { - closePanel(); - } - }, - true - ); - window.addEventListener("resize", closePanel); - function createBtn(tableName, columnsJson) { - const columns = parseColumnsJson(columnsJson); - const btn = document.createElement("button"); - btn.type = "button"; - btn.style.cssText = "background:none;border:none;padding:0;color:inherit;text-decoration:underline dotted;cursor:pointer;font-size:inherit;border-radius:2px;"; - btn.textContent = tableName; - btn.setAttribute("aria-label", `Show schema for ${tableName}`); - btn.setAttribute("aria-expanded", "false"); - btn.setAttribute("aria-haspopup", "dialog"); - const panel = document.createElement("div"); - panel.setAttribute("role", "dialog"); - panel.setAttribute("aria-label", `${tableName} schema`); - panel.style.cssText = PANEL_STYLE; - panel.hidden = true; - panel.innerHTML = renderTable(columns); - document.body.appendChild(panel); - btn.addEventListener("click", (e) => { - e.stopPropagation(); - if (activePanel && activePanel.panel === panel) { - closePanel(); - return; - } - closePanel(); - positionPanel(btn, panel); - panel.hidden = false; - btn.setAttribute("aria-expanded", "true"); - activePanel = { btn, panel }; - }); - panel.addEventListener("click", (e) => e.stopPropagation()); - return btn; - } - var style = document.createElement("style"); - style.textContent = ".qc-schema-display button:focus-visible{outline:2px solid currentColor;outline-offset:2px;border-radius:2px}"; - document.head.appendChild(style); - function processCollector(sentinel) { - const now = Date.now(); - const tableName = sentinel.dataset.table; - const btn = createBtn(tableName, sentinel.dataset.schemaJson); - if (lastDisplay && document.contains(lastDisplay) && now - lastDisplayTime < BATCH_MS) { - lastDisplay.appendChild(document.createTextNode(", ")); - lastDisplay.appendChild(btn); - sentinel.remove(); - } else { - const p = document.createElement("p"); - p.className = "qc-schema-display"; - p.style.cssText = "color:var(--bs-secondary-color,#6c757d);font-size:0.875em;margin:0.1rem 0;"; - p.appendChild(document.createTextNode("\u{1F50D} Fetched schemas: ")); - p.appendChild(btn); - sentinel.replaceWith(p); - lastDisplay = p; - } - lastDisplayTime = now; - } - new MutationObserver((mutations) => { - for (const { addedNodes } of mutations) { - for (const node of addedNodes) { - if (node.nodeType !== 1) continue; - if ( - /** @type {Element} */ - node.classList.contains("qc-schema-collector") - ) { - processCollector( - /** @type {HTMLElement} */ - node - ); - } else { - node.querySelectorAll(".qc-schema-collector").forEach((el) => processCollector( - /** @type {HTMLElement} */ - el - )); - } - } - } - }).observe(document.body, { subtree: true, childList: true }); -})(); diff --git a/pkg-r/inst/htmldep/viz.css b/pkg-r/inst/htmldep/viz.css index bbf54e6e6..0376c644f 100644 --- a/pkg-r/inst/htmldep/viz.css +++ b/pkg-r/inst/htmldep/viz.css @@ -36,8 +36,8 @@ display: inline-flex; align-items: center; gap: 4px; - padding: 2px 8px; - height: 28px; + padding: 1px 0.25rem; + height: 24px; border: none; border-radius: var(--bs-border-radius, 4px); background: transparent; @@ -54,24 +54,36 @@ } .querychat-query-chevron { - font-size: 0.625rem; - transition: transform 150ms; - display: inline-block; + display: grid; + place-items: center; + width: 0.9em; + height: 0.9em; + flex: none; + opacity: var(--_activity-opacity-muted, 0.65); + transform: rotate(-90deg); + transition: transform 300ms ease-in-out; +} + +.querychat-query-chevron svg { + display: block; + width: 100%; + height: 100%; } .querychat-query-chevron--expanded { - transform: rotate(90deg); + transform: rotate(0); } .querychat-icon { - width: 14px; - height: 14px; + width: 1em; + height: 1em; } .querychat-dropdown-chevron { - width: 12px; - height: 12px; + width: 0.9em; + height: 0.9em; margin-left: 2px; + opacity: var(--_activity-opacity-muted, 0.65); } .querychat-save-dropdown { @@ -117,7 +129,8 @@ display: none; position: relative; border-top: 1px solid var(--bs-border-color, #dee2e6); - margin: 8px -16px -8px; + margin: 0.25rem calc(-1 * var(--_querychat-footer-pad-x)) + calc(-1 * var(--_querychat-footer-pad-y)); } .querychat-query-section--visible { @@ -129,10 +142,30 @@ .shiny-tool-card:has(.querychat-viz-container) { max-height: 700px; overflow: hidden; + opacity: 1; } -.shiny-tool-card:has(.querychat-viz-container) > .card-footer { +/* shinychat dims tool activity (opacity: .7); the framed viz group header + should stay fully opaque like the card body it introduces */ +.shiny-chat-tool-group--framed:has(.querychat-viz-container) + > .shiny-chat-tool-group__row { + opacity: 1; +} + +.shiny-tool-card:has(.querychat-viz-container) > .card-footer, +/* Match (and beat, via load order) the specificity of shinychat's framed + group footer rule, which would otherwise force card-cap padding */ +.shiny-chat-tool-group--framed + > .shiny-chat-tool-call-row__detail + > .shiny-tool-card:has(.querychat-viz-container) + > .card-footer { + --_querychat-footer-pad-y: 0.125rem; + --_querychat-footer-pad-x: calc(var(--_row-pad, 0.4rem) - 0.25rem); + flex: 0 0 auto; + padding: var(--_querychat-footer-pad-y) var(--_querychat-footer-pad-x); + border-top: var(--bs-border-width, 1px) solid + var(--bs-border-color, #dee2e6); } .shiny-tool-card[fullscreen]:has(.querychat-viz-container) { diff --git a/pkg-r/tests/testthat/test-querychat_tools.R b/pkg-r/tests/testthat/test-querychat_tools.R index 96cb6bccf..46fbbc0f7 100644 --- a/pkg-r/tests/testthat/test-querychat_tools.R +++ b/pkg-r/tests/testthat/test-querychat_tools.R @@ -514,38 +514,102 @@ describe("tool_update_dashboard_impl()", { }) }) -describe("get_schema_result_display()", { - it("returns a sentinel span with data-table attribute", { - result <- GetSchemaResult( - value = "Table: orders\nColumns:\n- id (INTEGER)", - table_name = "orders" - ) - html <- get_schema_result_display(result) - html_str <- as.character(html) - expect_true(grepl("qc-schema-collector", html_str)) - expect_true(grepl('data-table="orders"', html_str)) - expect_true( - grepl("display:none", html_str) || grepl("display: none", html_str) - ) - }) - - it("embeds schema text in data-schema attribute", { - schema <- "Table: orders\nColumns:\n- id (INTEGER)" - result <- GetSchemaResult(value = schema, table_name = "orders") - html <- get_schema_result_display(result) - html_str <- as.character(html) - expect_true(grepl("data-schema", html_str)) - expect_true(grepl("orders", html_str)) - }) - - it("includes querychat-schema-display HTML dependency", { - result <- GetSchemaResult( - value = "Table: t\nColumns:\n- x (TEXT)", - table_name = "t" - ) - html <- get_schema_result_display(result) - deps <- htmltools::findDependencies(html) - dep_names <- vapply(deps, function(d) d$name, character(1)) - expect_true("querychat-schema-display" %in% dep_names) +describe("tool_get_schema()", { + skip_if_no_dataframe_engine() + + it("returns schema text with a native rich display", { + df_source <- local_data_frame_source(new_test_df()) + executor <- local_executor(df_source) + table_spec <- list( + columns = list( + list( + name = "id", + description = "Primary key", + units = "rows", + constraints = c("positive", "required"), + range = list(min = 1, max = 5) + ) + ) + ) + data_dicts <- list(list(tables = list(test_table = table_spec))) + expected <- executor$get_schema_result( + "test_table", + 20, + table_spec = table_spec + ) + tool <- tool_get_schema( + data_dicts = data_dicts, + executor = executor, + table_names = "test_table", + categorical_threshold = 20 + ) + + result <- tool(table_name = "test_table") + display <- result@extra$display + html <- as.character(display$html) + + expect_equal(tool@annotations$title, "Fetch schemas") + expect_equal(result@value, expected$text) + expect_s3_class(display, "shinychat_tool_result_display") + expect_equal(display$label, "test_table") + expect_equal(display$value_preview, "3 columns") + expect_false(display$show_request) + expect_false(display$open) + expect_match(html, '
', fixed = TRUE) + expect_match( + html, + '', + fixed = TRUE + ) + expect_match(html, '', fixed = TRUE) + expected_headers <- c( + "Column", + "Type", + "Range / Values", + "Description", + "Constraints" + ) + header_positions <- vapply( + paste0('"), + function(header) regexpr(header, html, fixed = TRUE)[[1]], + integer(1) + ) + id_cell_positions <- vapply( + c( + "id", + "INTEGER", + "", + "", + "" + ), + function(content) regexpr(content, html, fixed = TRUE)[[1]], + integer(1) + ) + expect_true(all(header_positions > 0)) + expect_true(all(id_cell_positions > 0)) + expect_true(all(diff(header_positions) > 0)) + expect_true(all(diff(id_cell_positions) > 0)) + expect_match( + html, + '' + ) + expect_match(html, "INTEGER", fixed = TRUE) + expect_match(html, "1 to 5", fixed = TRUE) + expect_match(html, "'A', 'B', 'C', 'D', 'E'", fixed = TRUE) + }) + + it("uses a singular preview for one column", { + df_source <- local_data_frame_source(data.frame(id = 1:3)) + executor <- local_executor(df_source) + tool <- tool_get_schema( + data_dicts = list(), + executor = executor, + table_names = "test_table", + categorical_threshold = 20 + ) + + result <- tool(table_name = "test_table") + + expect_equal(result@extra$display$value_preview, "1 column") }) }) diff --git a/pkg-r/tests/testthat/test-viz-tool.R b/pkg-r/tests/testthat/test-viz-tool.R index 89964ff0c..d20ec28a3 100644 --- a/pkg-r/tests/testthat/test-viz-tool.R +++ b/pkg-r/tests/testthat/test-viz-tool.R @@ -445,3 +445,66 @@ describe("collapse_validation_errors()", { ) }) }) + +describe("build_viz_footer()", { + it("uses the Shiny Chat disclosure chevron for Show Query", { + footer <- build_viz_footer( + "SELECT * FROM test_table VISUALISE value AS x DRAW histogram", + "Chart", + "querychat_viz_raw", + "querychat_viz_raw" + ) + html <- as.character(footer) + + expect_match(html, "querychat-query-chevron", fixed = TRUE) + expect_match(html, "bi-chevron-down", fixed = TRUE) + expect_no_match(html, "\u25b6", fixed = TRUE) + }) +}) + +describe("visualization tool display", { + skip_if_no_dataframe_engine() + skip_if_not_installed("ggsql") + + it("requests Shiny Chat's framed open style", { + ds <- local_data_frame_source(new_test_df()) + session <- structure( + list( + output = list(), + ns = identity + ), + class = "MockShinySession" + ) + + local_mocked_bindings( + execute_ggsql = function(...) structure(list(), class = "ggsql_spec"), + build_viz_footer = function(...) htmltools::tagList(), + .package = "querychat" + ) + local_mocked_bindings( + ggsql_validate = function(...) { + structure(list(valid = TRUE), class = "ggsql_validated") + }, + ggsql_has_visual = function(...) TRUE, + renderGgsql = function(...) shiny::renderText("ok"), + ggsqlOutput = function(id) htmltools::div(id = id), + ggsql_save = function(...) rlang::abort("V8 is not installed"), + .package = "ggsql" + ) + + tool <- tool_visualize_dashboard( + ds, + session = session, + update_fn = function(data) {} + ) + + suppressWarnings( + result <- tool( + ggsql = "SELECT * FROM test_table VISUALISE value AS x DRAW histogram", + title = "Test" + ) + ) + + expect_identical(result@extra$display$open_style, "framed") + }) +}) diff --git a/pyproject.toml b/pyproject.toml index 56c510659..cf228908c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "shiny>=1.6.2", "shinychat @ git+https://github.com/posit-dev/shinychat.git@main", "htmltools", - "chatlas>=0.18.0", + "chatlas>=0.21.2", "narwhals>=2.2.0", "chevron", "sqlalchemy>=2.0.0", # Using 2.0+ for improved type hints and API
', expected_headers, "1 to 5Primary keypositive, required\\s*id\\s*