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 (
- `` +
- `` +
- `| Column | ` +
- `Type | ` +
- `Description | ` +
- `Constraints | ` +
- `Range / Values | ` +
- `
` +
- `${rows}` +
- `
`
- );
-}
-
-// -- 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 `| Column | Type | Description | Constraints | Range / Values |
${rows}
`;
- }
- 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'| {header} | ' in html
+ assert re.search(
+ r'\s*order_id\s* | ',
+ 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 `| ${esc(col.name)} | ${typeCell} | ${col.description ? esc(col.description) : ""} | ${col.constraints ? esc(col.constraints) : ""} | ${details} |
`;
- }).join("");
- return `| Column | Type | Description | Constraints | Range / Values |
${rows}
`;
- }
- 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('| ', expected_headers, " | "),
+ function(header) regexpr(header, html, fixed = TRUE)[[1]],
+ integer(1)
+ )
+ id_cell_positions <- vapply(
+ c(
+ "id",
+ "INTEGER",
+ "1 to 5 | ",
+ "Primary key | ",
+ "positive, required | "
+ ),
+ 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,
+ '\\s*id\\s* | '
+ )
+ 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