diff --git a/pkg-r/R/chat.R b/pkg-r/R/chat.R index ead8b877..1040d6f6 100644 --- a/pkg-r/R/chat.R +++ b/pkg-r/R/chat.R @@ -1,6 +1,6 @@ #' Shiny chat UI and server for commons agents #' -#' These functions wrap [shinychat::chat_app()], [shinychat::chat_ui()], and +#' These functions wrap [shinychat::page_chat()], [shinychat::chat_ui()], and #' [shinychat::chat_server()] for commons agents. The server verifies each #' `` the model writes against its own context, measure #' definitions, and data documentation as the answer streams, and rewrites @@ -54,21 +54,24 @@ commons_app <- function(client, ...) { check_commons_client(client) ui <- function(req) { - bslib::page_fillable( - commons_ui( - "chat", - height = "100%", + with_commons_chat_assets( + shinychat::page_chat( + "commons", + id = "chat", enable_cancel = TRUE, - allow_attachments = TRUE - ), - if (rlang::is_interactive()) { - shiny::actionButton( - "close_btn", - label = "", - class = "btn-close", - style = "position: fixed; top: 6px; right: 6px;" + allow_attachments = TRUE, + toolbar_global = bslib::toolbar( + bslib::input_dark_mode(), + if (rlang::is_interactive()) { + shiny::actionButton( + "close_btn", + label = "", + class = "btn-close", + `aria-label` = "Close" + ) + } ) - } + ) ) } @@ -89,9 +92,7 @@ commons_app <- function(client, ...) { #' @export commons_ui <- function(id, ...) { check_chat_packages() - register_commons_icon_resources() - ui <- shinychat::chat_ui(id, icon_assistant = htmltools::HTML(""), ...) - htmltools::attachDependencies(ui, commons_chat_dependency(), append = TRUE) + with_commons_chat_assets(shinychat::chat_ui(id, ...)) } #' @rdname commons_app @@ -172,6 +173,11 @@ register_commons_icon_resources <- function() { ) } +with_commons_chat_assets <- function(ui) { + register_commons_icon_resources() + htmltools::attachDependencies(ui, commons_chat_dependency(), append = TRUE) +} + # Asset mtimes ride in the version so the dependency URL changes whenever # the files do; browsers otherwise cache edited assets under the stable # version's URL indefinitely. diff --git a/pkg-r/inst/www/commons-chat/commons-chat.css b/pkg-r/inst/www/commons-chat/commons-chat.css index 5b734354..e63e53dc 100644 --- a/pkg-r/inst/www/commons-chat/commons-chat.css +++ b/pkg-r/inst/www/commons-chat/commons-chat.css @@ -5,39 +5,149 @@ /* ---- Chat-level polish ---------------------------------------------- */ shiny-chat-container { - --shiny-chat-user-message-bg: var(--bs-tertiary-bg, #f4f5f7); - --shiny-tool-card-spinner-color: var(--bs-secondary-color, #6c757d); + --commons-chat-radius-control: 0.5rem; + --commons-chat-radius-surface: 0.75rem; + --commons-chat-radius-message: 0.75rem; + --commons-chat-radius-round: 999px; + --commons-chat-motion-fast: 120ms; + --commons-chat-state-hover: color-mix( + in srgb, + var(--bs-emphasis-color, #000) 6%, + transparent + ); + --commons-chat-state-active: color-mix( + in srgb, + var(--bs-emphasis-color, #000) 10%, + transparent + ); + --commons-chat-border-strong: color-mix( + in srgb, + var(--bs-body-color, #212529) 24%, + transparent + ); + --commons-chat-focus-color: var(--bs-primary, #007bc2); + --commons-chat-focus-ring: color-mix( + in srgb, + var(--bs-body-color, #212529) 10%, + transparent + ); + --commons-chat-shadow-control: + 0 1px 2px color-mix(in srgb, var(--bs-body-color, #212529) 8%, transparent), + 0 8px 24px -20px + color-mix(in srgb, var(--bs-body-color, #212529) 30%, transparent); + --shiny-chat-user-message-border-radius: var( + --commons-chat-radius-message + ); + --shiny-chat-user-message-padding: 0.5rem 1.5rem; + --shiny-chat-suggestion-card-border-radius: var( + --commons-chat-radius-surface + ); + --shiny-chat-streaming-color: var(--bs-primary, #007bc2); +} + +shiny-chat-container .shiny-chat-user-message:not(:has(.shiny-chat-edit-wrap)) { + max-width: min(85%, 42rem); +} + +shiny-chat-container .shiny-chat-input .tiptap { + --bs-border-radius: var(--commons-chat-radius-message); + background-color: var(--bs-body-bg, #fff); + border-color: var(--bs-border-color, #dee2e6); + box-shadow: var(--commons-chat-shadow-control); + line-height: 1.45; + min-height: 3rem; + padding-block: 0.65rem !important; + padding-right: 3rem !important; + transition: + border-color var(--commons-chat-motion-fast) ease, + box-shadow var(--commons-chat-motion-fast) ease, + background-color var(--commons-chat-motion-fast) ease; +} + +shiny-chat-container .shiny-chat-input:has(.shiny-chat-btn-attach) .tiptap { + padding-left: 3rem !important; +} + +shiny-chat-container .shiny-chat-input .tiptap:hover:not(:focus) { + border-color: var(--commons-chat-border-strong); } -.shiny-chat-user-message { - border-radius: 1.25rem; - padding: 0.65rem 1.1rem; +shiny-chat-container .shiny-chat-input .tiptap:focus { + border-color: var(--commons-chat-border-strong); + box-shadow: + 0 0 0 0.2rem var(--commons-chat-focus-ring), + var(--commons-chat-shadow-control); } -.shiny-chat-input .tiptap { +shiny-chat-container .shiny-chat-input:has(.shiny-chat-input-attachments) { + background-color: var(--bs-body-bg, #fff); border-color: var(--bs-border-color, #dee2e6); - box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); + border-radius: var(--commons-chat-radius-message); + box-shadow: var(--commons-chat-shadow-control); + transition: + border-color var(--commons-chat-motion-fast) ease, + box-shadow var(--commons-chat-motion-fast) ease; } -/* Send/stop keep shinychat's primary/danger hues, softened toward the - * background so they don't dominate the chat. */ -.shiny-chat-input .shiny-chat-btn-send { - color: color-mix(in srgb, var(--bs-primary, #007bc2) 60%, var(--bs-body-bg, #fff)); +shiny-chat-container + .shiny-chat-input:has(.shiny-chat-input-attachments):focus-within { + border-color: var(--commons-chat-border-strong); + box-shadow: + 0 0 0 0.2rem var(--commons-chat-focus-ring), + var(--commons-chat-shadow-control); } -.shiny-chat-input .shiny-chat-btn-send.shiny-chat-btn-cancel { - color: color-mix(in srgb, var(--bs-danger, #dc3545) 60%, var(--bs-body-bg, #fff)); +/* Shinychat owns the pending lifecycle; Commons replaces only its visuals. */ +shiny-chat-container .shiny-chat-pending-indicator { + gap: 0.5rem; } -.shiny-chat-input .shiny-chat-btn-send:disabled { - color: var(--bs-gray-400, #ced4da); +shiny-chat-container .shiny-chat-pending-indicator > svg { + display: none; +} + +shiny-chat-container .shiny-chat-pending-indicator::before { + background-color: var(--bs-secondary-color, #6c757d); + content: ""; + flex: none; + height: 0.4em; + margin-inline: 0.25em; + transform: rotate(45deg); + width: 0.4em; +} + +shiny-chat-container .shiny-chat-pending-indicator::after { + animation: commons-chat-working 1.6s ease-in-out infinite; + background: linear-gradient( + 90deg, + var(--bs-secondary-color, #6c757d) 25%, + var(--bs-body-color, #212529) 50%, + var(--bs-secondary-color, #6c757d) 75% + ); + background-clip: text; + background-size: 200% 100%; + color: var(--bs-secondary-color, #6c757d); + content: "Working…"; + font-size: 0.8em; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +@keyframes commons-chat-working { + from { + background-position: 150% 0; + } + + to { + background-position: -50% 0; + } } /* ---- Answer provenance ----------------------------------------------- */ .commons-answer-pill { align-items: center; - border-radius: 999px; + border-radius: var(--commons-chat-radius-round, 999px); display: inline-flex; font-size: 0.74rem; font-weight: 400; @@ -46,6 +156,10 @@ shiny-chat-container { max-width: 100%; padding: 0.18rem 0.45rem; position: relative; + transition: + background-color var(--commons-chat-motion-fast, 120ms) ease, + border-color var(--commons-chat-motion-fast, 120ms) ease, + color var(--commons-chat-motion-fast, 120ms) ease; vertical-align: text-bottom; } @@ -73,15 +187,33 @@ shiny-chat-container { color: #6b4b1b; } +.commons-answer-pill-trusted:is(:hover, :focus-visible, :focus-within) { + background: #e4f6ea; +} + +.commons-answer-pill-cited:is(:hover, :focus-visible, :focus-within) { + background: #e3f1f5; +} + +.commons-answer-pill-caution:is(:hover, :focus-visible, :focus-within) { + background: #fcefd8; +} + +.commons-answer-pill:focus-visible { + outline: 0.125rem solid var(--commons-chat-focus-color, currentcolor); + outline-offset: 0.1rem; +} + /* ---- Provenance tooltips ---------------------------------------------- */ /* Use a real element so JavaScript can keep tooltips inside the pane. */ .commons-tooltip { background: var(--bs-body-bg, #fff); border: 1px solid var(--bs-border-color, #dee2e6); - border-radius: 6px; + border-radius: var(--commons-chat-radius-control, 0.5rem); bottom: calc(100% + 0.45rem); - box-shadow: 0 6px 18px rgba(15, 23, 42, 0.12); + box-shadow: 0 6px 18px + color-mix(in srgb, var(--bs-body-color, #212529) 14%, transparent); color: var(--bs-body-color, #212529); display: none; font-size: 0.74rem; @@ -144,15 +276,13 @@ shiny-chat-container [data-shinychat-aside-display="compact"] { } /* Temporary bridge until Shinychat provides a public per-aside tone API. */ -shiny-chat-container - .shiny-aside-pill:has(img[src$="/trusted-icon.svg"]) { +shiny-chat-container .shiny-aside-pill:has(img[src$="/trusted-icon.svg"]) { background: #f2fbf5; box-shadow: inset 0 0 0 1px #cfeedd; color: #286144; } -shiny-chat-container - .shiny-aside-pill:has(img[src$="/trusted-icon.svg"]):hover, +shiny-chat-container .shiny-aside-pill:has(img[src$="/trusted-icon.svg"]):hover, shiny-chat-container .shiny-aside-pill:has(img[src$="/trusted-icon.svg"]):focus-visible, shiny-chat-container @@ -161,15 +291,13 @@ shiny-chat-container color: #204f38; } -shiny-chat-container - .shiny-aside-pill:has(img[src$="/warning-icon.svg"]) { +shiny-chat-container .shiny-aside-pill:has(img[src$="/warning-icon.svg"]) { background: #fff8ec; box-shadow: inset 0 0 0 1px #f2ddbb; color: #6b4b1b; } -shiny-chat-container - .shiny-aside-pill:has(img[src$="/warning-icon.svg"]):hover, +shiny-chat-container .shiny-aside-pill:has(img[src$="/warning-icon.svg"]):hover, shiny-chat-container .shiny-aside-pill:has(img[src$="/warning-icon.svg"]):focus-visible, shiny-chat-container @@ -178,6 +306,11 @@ shiny-chat-container color: #583b14; } +shiny-chat-container .shiny-aside-pill:focus-visible { + outline: 0.125rem solid var(--commons-chat-focus-color); + outline-offset: 0.1rem; +} + shiny-chat-container .shiny-aside-pill:has( img[src$="/trusted-icon.svg"], @@ -195,6 +328,87 @@ shiny-chat-container opacity: 1; } +[data-bs-theme="dark"] .commons-answer-pill-trusted, +[data-bs-theme="dark"] + shiny-chat-container + .shiny-aside-pill:has(img[src$="/trusted-icon.svg"]) { + background: color-mix( + in srgb, + var(--bs-success, #198754) 9%, + var(--bs-body-bg, #212529) + ); + color: var(--bs-success-text-emphasis, #75b798); +} + +[data-bs-theme="dark"] .commons-answer-pill-trusted { + border-color: var(--bs-success-border-subtle, #0f5132); +} + +[data-bs-theme="dark"] + .commons-answer-pill-trusted:is(:hover, :focus-visible, :focus-within) { + background: color-mix( + in srgb, + var(--bs-success, #198754) 15%, + var(--bs-body-bg, #212529) + ); +} + +[data-bs-theme="dark"] + shiny-chat-container + .shiny-aside-pill:has(img[src$="/trusted-icon.svg"]) { + box-shadow: inset 0 0 0 1px var(--bs-success-border-subtle, #0f5132); +} + +[data-bs-theme="dark"] .commons-answer-pill-cited { + background: color-mix( + in srgb, + var(--bs-info, #17a2b8) 9%, + var(--bs-body-bg, #212529) + ); + border-color: var(--bs-info-border-subtle, #087990); + color: var(--bs-info-text-emphasis, #6edff6); +} + +[data-bs-theme="dark"] + .commons-answer-pill-cited:is(:hover, :focus-visible, :focus-within) { + background: color-mix( + in srgb, + var(--bs-info, #17a2b8) 15%, + var(--bs-body-bg, #212529) + ); +} + +[data-bs-theme="dark"] .commons-answer-pill-caution, +[data-bs-theme="dark"] + shiny-chat-container + .shiny-aside-pill:has(img[src$="/warning-icon.svg"]) { + background: color-mix( + in srgb, + var(--bs-warning, #ffc107) 12%, + var(--bs-body-bg, #212529) + ); + color: var(--bs-warning-text-emphasis, #ffda6a); +} + +[data-bs-theme="dark"] .commons-answer-pill-caution { + border-color: var(--bs-warning-border-subtle, #997404); +} + +[data-bs-theme="dark"] + .commons-answer-pill-caution:is(:hover, :focus-visible, :focus-within) { + background: color-mix( + in srgb, + var(--bs-warning, #ffc107) 20%, + var(--bs-body-bg, #212529) + ); +} + +[data-bs-theme="dark"] + shiny-chat-container + .shiny-aside-pill:has(img[src$="/warning-icon.svg"]) { + box-shadow: inset 0 0 0 1px var(--bs-warning-border-subtle, #997404); +} + /* ---- Measure display ------------------------------------------------- */ .commons-measure-display { @@ -269,7 +483,7 @@ shiny-chat-container .commons-measure-plot { border: 1px solid var(--bs-border-color, #dee2e6); - border-radius: 0.5rem; + border-radius: var(--commons-chat-radius-surface, 0.75rem); display: block; height: auto; max-width: min(100%, 34rem); @@ -284,12 +498,33 @@ shiny-chat-container } .commons-run-r-details > summary { + border-radius: var(--commons-chat-radius-control, 0.5rem); color: var(--bs-secondary-color, #6c757d); cursor: pointer; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.07em; + margin-left: -0.4rem; + padding: 0.25rem 0.4rem; text-transform: uppercase; + transition: + background-color var(--commons-chat-motion-fast, 120ms) ease, + color var(--commons-chat-motion-fast, 120ms) ease; + width: fit-content; +} + +.commons-run-r-details > summary:hover { + background-color: var(--commons-chat-state-hover, rgba(0, 0, 0, 0.06)); + color: var(--bs-body-color, #212529); +} + +.commons-run-r-details > summary:active { + background-color: var(--commons-chat-state-active, rgba(0, 0, 0, 0.1)); +} + +.commons-run-r-details > summary:focus-visible { + outline: 0.125rem solid var(--commons-chat-focus-color, currentcolor); + outline-offset: 0.1rem; } .commons-run-r-code { @@ -304,7 +539,35 @@ shiny-chat-container .commons-run-r-plot { border: 1px solid var(--bs-border-color, #dee2e6); - border-radius: 0.5rem; + border-radius: var(--commons-chat-radius-surface, 0.75rem); height: auto; max-width: min(100%, 34rem); } + +@media (prefers-reduced-motion: reduce) { + shiny-chat-container .shiny-chat-pending-indicator::after { + animation: none; + background: none; + -webkit-text-fill-color: currentcolor; + } +} + +@media (prefers-contrast: more), (forced-colors: active) { + shiny-chat-container { + --commons-chat-border-strong: currentcolor; + --commons-chat-state-active: transparent; + --commons-chat-state-hover: transparent; + } + + shiny-chat-container .shiny-chat-pending-indicator::after { + animation: none; + background: none; + -webkit-text-fill-color: currentcolor; + } + + shiny-chat-container .shiny-aside-pill:focus-visible, + .commons-answer-pill:focus-visible, + .commons-run-r-details > summary:focus-visible { + outline-color: Highlight; + } +} diff --git a/pkg-r/man/commons_app.Rd b/pkg-r/man/commons_app.Rd index 92b86979..b34ea474 100644 --- a/pkg-r/man/commons_app.Rd +++ b/pkg-r/man/commons_app.Rd @@ -29,7 +29,7 @@ returns UI. \code{commons_server()} returns the \code{\link[shinychat:chat_serve result. } \description{ -These functions wrap \code{\link[shinychat:chat_app]{shinychat::chat_app()}}, \code{\link[shinychat:chat_ui]{shinychat::chat_ui()}}, and +These functions wrap \code{\link[shinychat:page_chat]{shinychat::page_chat()}}, \code{\link[shinychat:chat_ui]{shinychat::chat_ui()}}, and \code{\link[shinychat:chat_server]{shinychat::chat_server()}} for commons agents. The server verifies each \verb{} the model writes against its own context, measure definitions, and data documentation as the answer streams, and rewrites diff --git a/pkg-r/tests/testthat/apps/chat-pending/app.R b/pkg-r/tests/testthat/apps/chat-pending/app.R new file mode 100644 index 00000000..3bab3289 --- /dev/null +++ b/pkg-r/tests/testthat/apps/chat-pending/app.R @@ -0,0 +1,18 @@ +library(commons) + +ui <- bslib::page_fillable( + commons_ui( + "chat", + greeting = "Ask a question", + enable_cancel = TRUE + ) +) + +server <- function(input, output, session) { + shiny::observeEvent(input$chat_user_input, { + Sys.sleep(3) + shinychat::chat_append("chat", "The response is ready.") + }) +} + +shiny::shinyApp(ui, server) diff --git a/pkg-r/tests/testthat/test-chat-browser.R b/pkg-r/tests/testthat/test-chat-browser.R new file mode 100644 index 00000000..54ab38ec --- /dev/null +++ b/pkg-r/tests/testthat/test-chat-browser.R @@ -0,0 +1,77 @@ +test_that("Commons customizes shinychat's pending feedback", { + skip_on_cran() + skip_if_not_installed("shinytest2") + skip_if_not_installed("chromote") + skip_if_browser_tests_disabled() + + app <- shinytest2::AppDriver$new( + browser_test_app("chat-pending"), + name = "chat-pending", + timeout = 30 * 1000, + load_timeout = 30 * 1000 + ) + withr::defer(app$stop()) + + app$wait_for_js( + "document.querySelector('.suggestion');", + timeout = 30 * 1000 + ) + app$get_js( + "document.querySelector('.suggestion').click();" + ) + app$wait_for_js( + "document.querySelector('.shiny-chat-pending-indicator');", + timeout = 30 * 1000 + ) + app$wait_for_js( + paste0( + "getComputedStyle(document.querySelector(", + "'.shiny-chat-pending-indicator'", + ")).opacity !== '0';" + ), + timeout = 30 * 1000 + ) + + expect_identical( + app$get_js( + paste0( + "getComputedStyle(document.querySelector(", + "'.shiny-chat-pending-indicator'", + "), '::after').content;" + ) + ), + '"Working…"' + ) + expect_identical( + app$get_js( + paste0( + "getComputedStyle(document.querySelector(", + "'.shiny-chat-pending-indicator > svg'", + ")).display;" + ) + ), + "none" + ) + expect_identical( + app$get_js( + paste0( + "document.querySelector('", + ".shiny-chat-btn-send[aria-label=\"Loading\"], ", + ".shiny-chat-btn-cancel[aria-label=\"Stop generating\"]", + "') !== null;" + ) + ), + TRUE + ) + + app$wait_for_js( + "document.body.innerText.includes('The response is ready.');", + timeout = 30 * 1000 + ) + expect_identical( + app$get_js( + "document.querySelector('.shiny-chat-pending-indicator') === null;" + ), + TRUE + ) +}) diff --git a/pkg-r/tests/testthat/test-chat.R b/pkg-r/tests/testthat/test-chat.R index 74335afe..8922a899 100644 --- a/pkg-r/tests/testthat/test-chat.R +++ b/pkg-r/tests/testthat/test-chat.R @@ -1,4 +1,4 @@ -test_that("commons_app builds a single-user app from commons chat wrappers", { +test_that("commons_app builds a single-user page chat", { skip_if_not_installed("shiny") skip_if_not_installed("shinychat") @@ -9,9 +9,12 @@ test_that("commons_app builds a single-user app from commons chat wrappers", { app_env <- environment(app$serverFuncSource) ui <- app_env$ui(NULL) + page <- htmltools::tagQuery(ui)$find("shiny-chat-page")$selectedTags() chat <- htmltools::tagQuery(ui)$find("shiny-chat-container")$selectedTags() deps <- htmltools::findDependencies(ui) + expect_length(page, 1) + expect_identical(page[[1]]$attribs$id, "chat_page") expect_length(chat, 1) expect_true(is.na(chat[[1]]$attribs[["allow-attachments"]])) expect_true(is.na(chat[[1]]$attribs[["enable-cancel"]]))