From 8664f85ed6d52a16e9ea056bcb513fe55e0e629e Mon Sep 17 00:00:00 2001 From: skaltman Date: Thu, 30 Jul 2026 16:47:11 -0700 Subject: [PATCH 01/38] Add basic trajectory viewer --- DESCRIPTION | 1 + NAMESPACE | 1 + R/tagging.R | 34 +- R/trajectories.R | 17 +- R/view-trajectories.R | 467 +++++++++++++++++++++ inst/www/commons-viewer/commons-viewer.css | 61 +++ man/read_trajectories.Rd | 4 +- man/view_trajectories.Rd | 39 ++ tests/testthat/_snaps/view-trajectories.md | 16 + tests/testthat/test-trajectories.R | 9 + tests/testthat/test-view-trajectories.R | 233 ++++++++++ 11 files changed, 866 insertions(+), 16 deletions(-) create mode 100644 R/view-trajectories.R create mode 100644 inst/www/commons-viewer/commons-viewer.css create mode 100644 man/view_trajectories.Rd create mode 100644 tests/testthat/_snaps/view-trajectories.md create mode 100644 tests/testthat/test-view-trajectories.R diff --git a/DESCRIPTION b/DESCRIPTION index 1aeaabc..e9c9b37 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -37,6 +37,7 @@ Imports: utils Suggests: bsicons, + bslib, dbplyr, dplyr, glue, diff --git a/NAMESPACE b/NAMESPACE index 518f8e9..6e3d88a 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -9,6 +9,7 @@ export(list_tables) export(measure) export(read_trajectories) export(semantic_layer) +export(view_trajectories) importFrom(R6,R6Class) importFrom(coro,async_generator) importFrom(coro,await_each) diff --git a/R/tagging.R b/R/tagging.R index da3edf4..6bc0193 100644 --- a/R/tagging.R +++ b/R/tagging.R @@ -28,25 +28,33 @@ commons_last_provenance <- function(client) { # reinstate provenance pills when an existing conversation seeds a new # session, since pills are otherwise only injected as live turns complete. commons_exchange_provenance <- function(turns, corpus = list()) { + lapply(split_exchanges(turns), function(exchange) { + derive_provenance( + unlist(lapply(exchange, turn_tags)) %||% character(), + unlist(lapply(exchange, turn_text)) %||% character(), + corpus + ) + }) +} + +# Turns split into question -> answer exchanges: each exchange opens at a +# user turn carrying no tool results and runs until the next one. Turns +# before the first such user turn (e.g. system turns) belong to no exchange. +split_exchanges <- function(turns) { out <- list() - tags <- character() - text <- character() - started <- FALSE + current <- NULL for (turn in turns) { if (identical(turn@role, "user") && !turn_has_tool_result(turn)) { - if (started) { - out[[length(out) + 1]] <- derive_provenance(tags, text, corpus) + if (!is.null(current)) { + out[[length(out) + 1]] <- current } - tags <- character() - text <- character() - started <- TRUE - } else if (started) { - tags <- c(tags, turn_tags(turn)) - text <- c(text, turn_text(turn)) + current <- list(turn) + } else if (!is.null(current)) { + current[[length(current) + 1]] <- turn } } - if (started) { - out[[length(out) + 1]] <- derive_provenance(tags, text, corpus) + if (!is.null(current)) { + out[[length(out) + 1]] <- current } out } diff --git a/R/trajectories.R b/R/trajectories.R index 8452671..95983d1 100644 --- a/R/trajectories.R +++ b/R/trajectories.R @@ -51,7 +51,9 @@ #' ``` #' #' @return A list of conversations, named by conversation id and ordered -#' oldest-first. Each conversation is a list of [ellmer::Turn]s. +#' oldest-first. Each conversation is a list of [ellmer::Turn]s and carries +#' a `last_active` attribute: a `POSIXct` giving the time of the +#' conversation's most recent chat activity. #' @export read_trajectories <- function( source = NULL, @@ -527,8 +529,19 @@ posixct_nanos <- function(time) { # ellmer's chat spans repeat the full message history, so the latest chat # span in a conversation carries the whole trajectory: group chat spans by # conversation, keep the last one, and parse its GenAI-semconv messages. +# That span's time is also the conversation's last activity. build_trajectories <- function(spans) { - lapply(latest_chat_spans(spans), trajectory_turns) + lapply(latest_chat_spans(spans), function(span) { + turns <- trajectory_turns(span) + attr(turns, "last_active") <- nano_posixct(span_time(span)) + turns + }) +} + +# Doubles can't hold nanosecond precision, but second-level precision is all +# a last-activity time needs. (Explicit origin: required on R < 4.3.) +nano_posixct <- function(time) { + as.POSIXct(as.numeric(time) / 1e9, origin = "1970-01-01") } # The latest chat span per conversation, named by conversation id and diff --git a/R/view-trajectories.R b/R/view-trajectories.R new file mode 100644 index 0000000..6405e5b --- /dev/null +++ b/R/view-trajectories.R @@ -0,0 +1,467 @@ +#' View commons trajectories +#' +#' @description +#' `view_trajectories()` launches a Shiny app for browsing conversation +#' trajectories read with [read_trajectories()]. The app shows the rate of +#' each trust level across conversations, a conversation list that can be +#' filtered by date, and a read-only transcript of each conversation with the +#' provenance pills the commons chat UI would show. +#' +#' Trajectories carry no record of how each answer was tagged when it was +#' produced, so the viewer derives trust levels from the tool calls in the +#' trajectory: answers backed only by governed tools (`call_measure`, +#' `call_metrics`) are verified, and answers that used fallback tools +#' (`run_sql`, `run_r`) count as cited when they contain citation markup and +#' untrusted when they don't. Citation quotes are not re-verified against the +#' agent's context. +#' +#' @param trajectories A named list of conversations, as returned by +#' [read_trajectories()]. +#' +#' @return A [shiny::shinyApp()] object. Calling `view_trajectories()` at the +#' console launches the viewer; the result can also be served as the last +#' expression of an `app.R`. +#' +#' @examples +#' \dontrun{ +#' view_trajectories() +#' +#' view_trajectories(read_trajectories(from = "2026-07-01")) +#' } +#' @export +view_trajectories <- function(trajectories = read_trajectories()) { + check_viewer_packages() + check_trajectories(trajectories) + summary <- summarize_trajectories(trajectories) + shiny::shinyApp(viewer_ui(summary), viewer_server(trajectories, summary)) +} + +check_viewer_packages <- function(call = rlang::caller_env()) { + pkgs <- c("bslib", "htmltools", "shiny", "shinychat") + missing <- pkgs[!vapply(pkgs, requireNamespace, logical(1), quietly = TRUE)] + + if (length(missing)) { + cli::cli_abort( + c( + "{.fn view_trajectories} requires missing package{?s}: {.pkg {missing}}.", + i = "Install {.pkg {missing}} to use the trajectory viewer." + ), + call = call + ) + } +} + +check_trajectories <- function(trajectories, call = rlang::caller_env()) { + ok <- is.list(trajectories) && + (length(trajectories) == 0 || !is.null(names(trajectories))) && + all(vapply(trajectories, is.list, logical(1))) + + if (!ok) { + cli::cli_abort( + "{.arg trajectories} must be a named list of conversations as returned + by {.fn read_trajectories}: each a list of {.cls ellmer::Turn}s.", + call = call + ) + } +} + +# Summaries --------------------------------------------------------------- + +# One record per conversation, in input order. A plain list of records +# rather than a data frame: the app maps over conversations anyway. +summarize_trajectories <- function(trajectories) { + unname(Map(conversation_record, names(trajectories), trajectories)) +} + +conversation_record <- function(id, turns) { + exchanges <- split_exchanges(turns) + provenance <- lapply(exchanges, exchange_provenance) + list( + id = id, + snippet = first_user_snippet(exchanges), + n_user_turns = length(exchanges), + tags = vapply(provenance, function(p) p$tag, character(1)), + last_active = attr(turns, "last_active") %||% as.POSIXct(NA) + ) +} + +# The viewer re-derives what runtime tagging stores in extra$commons_tag -- +# dropped by the OTLP round trip -- from the tool-call names that survive +# it. B vs C is decided by citation *presence*: with no agent there is no +# corpus to verify quotes against. +trajectory_provenance <- function(turns) { + lapply(split_exchanges(turns), exchange_provenance) +} + +exchange_provenance <- function(exchange) { + tags <- exchange_tool_tags(exchange) + text <- unlist(lapply(exchange, turn_text)) %||% character() + citations <- extract_citations(text) + tag <- if ("B" %in% tags) { + if (length(citations) > 0) "B" else "C" + } else if ("A" %in% tags) { + "A" + } else { + NA_character_ + } + list(tag = tag, citations = citations) +} + +viewer_tool_tags <- c( + call_measure = "A", + call_metrics = "A", + run_sql = "B", + run_r = "B" +) + +# Tags come from tool requests rather than results: reconstructed histories +# always pair them, and a request never depends on the result's back-pointer +# having been re-linked. +exchange_tool_tags <- function(turns) { + calls <- unlist(lapply(turns, function(turn) { + lapply(turn@contents, function(content) { + if (S7::S7_inherits(content, ellmer::ContentToolRequest)) { + content@name + } + }) + })) + tags <- viewer_tool_tags[calls] + unname(tags[!is.na(tags)]) +} + +first_user_snippet <- function(exchanges, max_chars = 80) { + if (length(exchanges) == 0) { + return("") + } + text <- trimws(gsub("\\s+", " ", exchanges[[1]][[1]]@text)) + if (nchar(text) <= max_chars) { + return(text) + } + paste0(substr(text, 1, max_chars - 1), "…") +} + +# Exchange-level counts of each trust level across a set of conversations' +# tag vectors. +hit_rate <- function(tag_sets) { + tags <- unlist(tag_sets) %||% character() + list( + n = length(tags), + counts = c( + A = sum(tags %in% "A"), + B = sum(tags %in% "B"), + C = sum(tags %in% "C"), + none = sum(is.na(tags)) + ) + ) +} + +# Transcripts ------------------------------------------------------------- + +# A conversation as shinychat-ready messages plus the pill-seed payload for +# the commonsProvenancePillSeed handler (see commons-chat.js). One user +# message per exchange opener; the rest of each exchange merges into one +# assistant message whose chunks are markdown strings and tool-result cards. +# Tool requests are dropped, mirroring shinychat's own transcript restore +# (each result card carries its request). `count` and `indexFromEnd` index +# assistant messages, which is what the seed handler counts. +trajectory_transcript <- function(turns) { + exchanges <- split_exchanges(turns) + messages <- list() + pills <- list() + n_assistant <- 0L + + for (exchange in exchanges) { + messages[[length(messages) + 1]] <- list( + role = "user", + content = exchange[[1]]@text + ) + chunks <- exchange_answer_chunks(exchange[-1]) + if (length(chunks) == 0) { + next + } + n_assistant <- n_assistant + 1L + messages[[length(messages) + 1]] <- list( + role = "assistant", + content = chunks + ) + pill <- viewer_pill(exchange_provenance(exchange), n_assistant) + if (!is.null(pill)) { + pills[[length(pills) + 1]] <- pill + } + } + + for (i in seq_along(pills)) { + pills[[i]]$indexFromEnd <- n_assistant - pills[[i]]$indexFromEnd + } + list(messages = messages, count = n_assistant, pills = pills) +} + +# Exchanges whose tag is NA and whose answer attempted no citations need +# neither a pill nor citation cleanup. Cited ("B") answers send an empty +# pill: the client still strips their markup before finding no +# pill to place. All citations go over unverified, so none become footnotes. +viewer_pill <- function(provenance, assistant_index) { + if (is.na(provenance$tag) && length(provenance$citations) == 0) { + return(NULL) + } + list( + html = htmltools::renderTags(commons_answer_pill(provenance$tag))$html, + citations = lapply(provenance$citations, function(x) list(verified = FALSE)), + indexFromEnd = assistant_index + ) +} + +exchange_answer_chunks <- function(turns) { + chunks <- list() + for (turn in turns) { + for (content in turn@contents) { + if (S7::S7_inherits(content, ellmer::ContentToolRequest)) { + next + } + chunks[[length(chunks) + 1]] <- shinychat::contents_shinychat(content) + } + } + drop_nulls(chunks) +} + +# Replays a conversation into a bound chat element. Assistant messages +# stream as chunks -- the mode the pill-seed handler was designed around -- +# and the seed is sent last so pills land once the transcript settles. +restore_transcript <- function(session, id, turns) { + transcript <- trajectory_transcript(turns) + for (message in transcript$messages) { + if (identical(message$role, "user")) { + shinychat::chat_append_message(id, message, chunk = FALSE, session = session) + next + } + shinychat::chat_append_message( + id, + list(role = "assistant", content = ""), + chunk = "start", + session = session + ) + for (chunk in message$content) { + shinychat::chat_append_message( + id, + list(role = "assistant", content = chunk), + chunk = TRUE, + session = session + ) + } + shinychat::chat_append_message( + id, + list(role = "assistant", content = ""), + chunk = "end", + session = session + ) + } + if (length(transcript$pills) > 0) { + session$sendCustomMessage( + "commonsProvenancePillSeed", + list(id = id, count = transcript$count, pills = transcript$pills) + ) + } +} + +# App --------------------------------------------------------------------- + +viewer_ui <- function(summary) { + dates <- viewer_date_range(summary) + htmltools::attachDependencies( + bslib::page_sidebar( + title = "Conversations", + sidebar = bslib::sidebar( + width = 380, + shiny::dateRangeInput( + "window", + "Active between", + start = dates$min, + end = dates$max, + min = dates$min, + max = dates$max + ), + shiny::uiOutput("conversations") + ), + shiny::uiOutput("hit_rate"), + bslib::card( + fill = TRUE, + class = "commons-viewer-transcript", + shiny::uiOutput("transcript", fill = TRUE) + ) + ), + list(commons_chat_dependency(), commons_viewer_dependency()) + ) +} + +viewer_server <- function(trajectories, summary) { + function(input, output, session) { + selected <- shiny::reactiveVal(NULL) + + visible <- shiny::reactive({ + window <- input$window + Filter( + function(i) in_window(summary[[i]], window), + seq_along(summary) + ) + }) + + output$hit_rate <- shiny::renderUI({ + hit_rate_boxes(hit_rate(lapply(visible(), function(i) summary[[i]]$tags))) + }) + + output$conversations <- shiny::renderUI({ + indices <- visible() + if (length(indices) == 0) { + return(viewer_empty_note(if (length(summary) == 0) { + "No conversations to view." + } else { + "No conversations in this date range." + })) + } + lapply(indices, function(i) { + conversation_entry(i, summary[[i]], selected = identical(selected(), i)) + }) + }) + + # Entry ids index into `trajectories`, which never changes, so observers + # registered once stay valid as the date filter re-renders the list. + for (i in seq_along(trajectories)) { + local({ + index <- i + shiny::observeEvent(input[[paste0("conversation_", index)]], { + selected(index) + }) + }) + } + + # A fresh chat element per selection: a superseded pill-seed timer from a + # fast conversation switch then targets a defunct element id and dies + # harmlessly instead of racing the new transcript. + output$transcript <- shiny::renderUI({ + i <- selected() + if (is.null(i)) { + return(viewer_empty_note("Select a conversation to view its transcript.")) + } + commons_ui(paste0("transcript_", i), height = "100%") + }) + + # onFlushed fires after the flush that delivers the new chat element, so + # it is bound client-side before the replayed messages arrive -- the same + # mechanism commons_server() uses to seed pills. + shiny::observeEvent(selected(), { + i <- selected() + session$onFlushed( + function() { + restore_transcript(session, paste0("transcript_", i), trajectories[[i]]) + }, + once = TRUE + ) + }) + } +} + +viewer_levels <- c( + A = "Verified", + B = "Cited", + C = "Untrusted", + none = "No data tool" +) + +hit_rate_boxes <- function(rate) { + boxes <- lapply(names(viewer_levels), function(key) { + bslib::value_box( + title = viewer_levels[[key]], + value = rate_percent(rate$counts[[key]], rate$n), + htmltools::p(sprintf("%d of %d answers", rate$counts[[key]], rate$n)) + ) + }) + do.call(bslib::layout_columns, c(boxes, list(fill = FALSE))) +} + +rate_percent <- function(count, n) { + if (n == 0) { + return("—") + } + sprintf("%.0f%%", 100 * count / n) +} + +conversation_entry <- function(index, record, selected = FALSE) { + pills <- lapply(intersect(c("A", "C"), record$tags), commons_answer_pill) + shiny::actionLink( + paste0("conversation_", index), + class = if (selected) { + "commons-viewer-entry commons-viewer-entry-selected" + } else { + "commons-viewer-entry" + }, + label = htmltools::tagList( + htmltools::div(class = "commons-viewer-entry-snippet", record$snippet), + htmltools::div( + class = "commons-viewer-entry-meta", + htmltools::tags$span(entry_meta(record)), + pills + ) + ) + ) +} + +entry_meta <- function(record) { + turns <- sprintf( + "%d %s", + record$n_user_turns, + if (record$n_user_turns == 1) "turn" else "turns" + ) + date <- local_date(record$last_active) + if (is.na(date)) { + return(turns) + } + sprintf("%s · %s", turns, format(date, "%b %e, %Y")) +} + +viewer_empty_note <- function(text) { + htmltools::div(class = "commons-viewer-empty", text) +} + +# Conversations without a timestamp always pass the date filter. +in_window <- function(record, window) { + date <- local_date(record$last_active) + is.na(date) || + ((is.na(window[[1]]) || date >= window[[1]]) && + (is.na(window[[2]]) || date <= window[[2]])) +} + +# The date filter's bounds; when no conversation carries a timestamp, both +# collapse to today and the filter is inert (NA times always pass). +viewer_date_range <- function(summary) { + dates <- as.Date(vapply( + summary, + function(record) as.character(local_date(record$last_active)), + character(1) + )) + dates <- dates[!is.na(dates)] + if (length(dates) == 0) { + return(list(min = Sys.Date(), max = Sys.Date())) + } + list(min = min(dates), max = max(dates)) +} + +# as.Date() on a POSIXct reads it in UTC; the viewer filters and displays +# calendar days in the reader's local time. +local_date <- function(time) { + as.Date(format(time, "%Y-%m-%d")) +} + +# 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. +commons_viewer_dependency <- function() { + src <- system.file("www", "commons-viewer", package = "commons") + stamp <- max(file.mtime(list.files(src, full.names = TRUE))) + + htmltools::htmlDependency( + name = "commons-viewer", + version = paste0("0.0.0.9000.", as.integer(stamp)), + src = c(file = src), + stylesheet = "commons-viewer.css" + ) +} diff --git a/inst/www/commons-viewer/commons-viewer.css b/inst/www/commons-viewer/commons-viewer.css new file mode 100644 index 0000000..7d613b9 --- /dev/null +++ b/inst/www/commons-viewer/commons-viewer.css @@ -0,0 +1,61 @@ +/* Read-only transcripts: the input box is hidden rather than removed so a + * future feedback chat can bring it back by deleting this rule. */ +.commons-viewer-transcript shiny-chat-input { + display: none; +} + +/* ---- Conversation list ------------------------------------------------- */ + +.commons-viewer-entry { + border-radius: 0.5rem; + color: inherit; + display: block; + padding: 0.5rem 0.75rem; + text-decoration: none; +} + +.commons-viewer-entry:hover, +.commons-viewer-entry:focus { + background: var(--bs-secondary-bg, #f1f3f5); + color: inherit; + text-decoration: none; +} + +.commons-viewer-entry-selected, +.commons-viewer-entry-selected:hover, +.commons-viewer-entry-selected:focus { + background: var(--bs-primary-bg-subtle, #e7f1ff); +} + +.commons-viewer-entry-snippet { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.commons-viewer-entry-meta { + align-items: center; + color: var(--bs-secondary-color, #6c757d); + display: flex; + flex-wrap: wrap; + font-size: 0.8125rem; + gap: 0.4rem; + margin-top: 0.125rem; +} + +.commons-viewer-entry-meta .commons-answer-pill { + font-size: 0.68rem; + padding: 0.12rem 0.4rem; +} + +.commons-viewer-entry-meta .commons-answer-pill-icon { + height: 0.75rem; + width: 0.75rem; +} + +.commons-viewer-empty { + color: var(--bs-secondary-color, #6c757d); + padding: 1rem 0; + text-align: center; +} diff --git a/man/read_trajectories.Rd b/man/read_trajectories.Rd index a96b069..33f9eaa 100644 --- a/man/read_trajectories.Rd +++ b/man/read_trajectories.Rd @@ -32,7 +32,9 @@ history as of \code{to}.} } \value{ A list of conversations, named by conversation id and ordered -oldest-first. Each conversation is a list of \link[ellmer:Turn]{ellmer::Turn}s. +oldest-first. Each conversation is a list of \link[ellmer:Turn]{ellmer::Turn}s and carries +a \code{last_active} attribute: a \code{POSIXct} giving the time of the +conversation's most recent chat activity. } \description{ \code{read_trajectories()} reads conversation trajectories captured by diff --git a/man/view_trajectories.Rd b/man/view_trajectories.Rd new file mode 100644 index 0000000..230a414 --- /dev/null +++ b/man/view_trajectories.Rd @@ -0,0 +1,39 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/view-trajectories.R +\name{view_trajectories} +\alias{view_trajectories} +\title{View commons trajectories} +\usage{ +view_trajectories(trajectories = read_trajectories()) +} +\arguments{ +\item{trajectories}{A named list of conversations, as returned by +\code{\link[=read_trajectories]{read_trajectories()}}.} +} +\value{ +A \code{\link[shiny:shinyApp]{shiny::shinyApp()}} object. Calling \code{view_trajectories()} at the +console launches the viewer; the result can also be served as the last +expression of an \code{app.R}. +} +\description{ +\code{view_trajectories()} launches a Shiny app for browsing conversation +trajectories read with \code{\link[=read_trajectories]{read_trajectories()}}. The app shows the rate of +each trust level across conversations, a conversation list that can be +filtered by date, and a read-only transcript of each conversation with the +provenance pills the commons chat UI would show. + +Trajectories carry no record of how each answer was tagged when it was +produced, so the viewer derives trust levels from the tool calls in the +trajectory: answers backed only by governed tools (\code{call_measure}, +\code{call_metrics}) are verified, and answers that used fallback tools +(\code{run_sql}, \code{run_r}) count as cited when they contain citation markup and +untrusted when they don't. Citation quotes are not re-verified against the +agent's context. +} +\examples{ +\dontrun{ +view_trajectories() + +view_trajectories(read_trajectories(from = "2026-07-01")) +} +} diff --git a/tests/testthat/_snaps/view-trajectories.md b/tests/testthat/_snaps/view-trajectories.md new file mode 100644 index 0000000..81cb002 --- /dev/null +++ b/tests/testthat/_snaps/view-trajectories.md @@ -0,0 +1,16 @@ +# check_trajectories accepts empty trajectories and rejects other shapes + + Code + check_trajectories("nope") + Condition + Error: + ! `trajectories` must be a named list of conversations as returned by `read_trajectories()`: each a list of s. + +--- + + Code + check_trajectories(list(list())) + Condition + Error: + ! `trajectories` must be a named list of conversations as returned by `read_trajectories()`: each a list of s. + diff --git a/tests/testthat/test-trajectories.R b/tests/testthat/test-trajectories.R index 8412891..c0e639b 100644 --- a/tests/testthat/test-trajectories.R +++ b/tests/testthat/test-trajectories.R @@ -62,6 +62,15 @@ test_that("trajectories rebuild ellmer turns from semconv messages", { expect_equal(turns[[5]]@text, "You rolled a 4.") }) +test_that("conversations carry their last chat activity time", { + trajectories <- build_trajectories(parse_otlp_lines(staggered_test_line())) + + last_active <- attr(trajectories[[1]], "last_active") + expect_s3_class(last_active, "POSIXct") + # The t100 span ends at 101s past the epoch. + expect_equal(as.numeric(last_active), 101) +}) + test_that("rebuilt turns can be set on an ellmer chat", { json <- test_turn_json() spans <- parse_otlp_lines(otlp_test_line(list( diff --git a/tests/testthat/test-view-trajectories.R b/tests/testthat/test-view-trajectories.R new file mode 100644 index 0000000..ffe5dc2 --- /dev/null +++ b/tests/testthat/test-view-trajectories.R @@ -0,0 +1,233 @@ +# An exchange fragment: an assistant tool call and its tool-result turn, +# named like the tool the viewer derives trust levels from. +test_tool_turns <- function(name, id = "c1") { + request <- ellmer::ContentToolRequest(id = id, name = name, arguments = list()) + list( + ellmer::AssistantTurn(list(request)), + ellmer::UserTurn(list(ellmer::ContentToolResult(value = "ok", request = request))) + ) +} + +test_that("trajectory_provenance derives tags from tool calls and citations", { + measure <- c( + list(ellmer::UserTurn("How many orders?")), + test_tool_turns("call_measure"), + list(ellmer::AssistantTurn("6 orders.")) + ) + expect_equal(trajectory_provenance(measure)[[1]]$tag, "A") + + uncited <- c( + list(ellmer::UserTurn("Total revenue?")), + test_tool_turns("run_sql"), + list(ellmer::AssistantTurn("5650.")) + ) + expect_equal(trajectory_provenance(uncited)[[1]]$tag, "C") + + # Citation *presence* makes a fallback answer "B": with no agent there is + # no corpus, so even an unverifiable quote counts. + cited <- c( + list(ellmer::UserTurn("Total revenue?")), + test_tool_turns("run_sql"), + list(ellmer::AssistantTurn( + "5650.\n\nRevenue excludes tax." + )) + ) + expect_equal(trajectory_provenance(cited)[[1]]$tag, "B") + + mixed <- c( + list(ellmer::UserTurn("Total revenue?")), + test_tool_turns("call_measure", id = "c1"), + test_tool_turns("run_sql", id = "c2"), + list(ellmer::AssistantTurn("5650. Revenue excludes tax.")) + ) + expect_equal(trajectory_provenance(mixed)[[1]]$tag, "B") + + untagged <- c( + list(ellmer::UserTurn("What does revenue mean?")), + test_tool_turns("search_context"), + list(ellmer::AssistantTurn("Revenue excludes tax.")) + ) + expect_true(is.na(trajectory_provenance(untagged)[[1]]$tag)) +}) + +test_that("tool names survive the OTLP round trip and drive derivation", { + input <- paste0( + '[{"role":"user","parts":[{"type":"text","content":"Total revenue?"}]},', + '{"role":"assistant","parts":[{"type":"tool_call","id":"c1",', + '"name":"run_sql","arguments":{"sql":"select 1"}}]},', + '{"role":"tool","parts":[{"type":"tool_call_response","id":"c1",', + '"response":"5650"}]}]' + ) + output <- paste0( + '[{"role":"assistant","parts":[{"type":"text",', + '"content":"5650.\\n\\nRevenue excludes tax."}]}]' + ) + spans <- parse_otlp_lines(otlp_test_line(list( + chat_test_span("t1", "s1", input_messages = input, output_messages = output) + ))) + + turns <- build_trajectories(spans)[[1]] + provenance <- trajectory_provenance(turns) + + expect_length(provenance, 1) + expect_equal(provenance[[1]]$tag, "B") +}) + +test_that("split_exchanges opens at plain user turns only", { + turns <- c( + list( + ellmer::SystemTurn("Be helpful."), + ellmer::UserTurn("How many orders?") + ), + test_tool_turns("call_measure"), + list( + ellmer::AssistantTurn("6 orders."), + ellmer::UserTurn("Thanks!") + ) + ) + + exchanges <- split_exchanges(turns) + + expect_length(exchanges, 2) + # The system turn belongs to no exchange; the tool-result UserTurn stays + # inside its exchange rather than opening one. + expect_length(exchanges[[1]], 4) + expect_equal(exchanges[[1]][[1]]@text, "How many orders?") + expect_length(exchanges[[2]], 1) +}) + +test_that("summarize_trajectories describes each conversation", { + active <- c( + list(ellmer::UserTurn("How many\norders came in last week?")), + test_tool_turns("call_measure"), + list( + ellmer::AssistantTurn("6 orders."), + ellmer::UserTurn("Total revenue?") + ), + test_tool_turns("run_sql", id = "c2"), + list(ellmer::AssistantTurn("5650.")) + ) + attr(active, "last_active") <- as.POSIXct("2026-07-22 14:30:00") + trajectories <- list( + conv1 = active, + conv2 = list( + ellmer::UserTurn("What does revenue mean?"), + ellmer::AssistantTurn("Revenue excludes tax.") + ) + ) + + summary <- summarize_trajectories(trajectories) + + expect_length(summary, 2) + expect_equal(summary[[1]]$id, "conv1") + expect_equal(summary[[1]]$snippet, "How many orders came in last week?") + expect_equal(summary[[1]]$n_user_turns, 2) + expect_equal(summary[[1]]$tags, c("A", "C")) + expect_equal(summary[[1]]$last_active, as.POSIXct("2026-07-22 14:30:00")) + expect_equal(summary[[2]]$tags, NA_character_) + expect_true(is.na(summary[[2]]$last_active)) +}) + +test_that("hit_rate counts exchange tags across conversations", { + rate <- hit_rate(list(c("A", "C"), "B", NA_character_)) + + expect_equal(rate$n, 4) + expect_equal(rate$counts, c(A = 1, B = 1, C = 1, none = 1)) +}) + +test_that("trajectory_transcript merges each exchange into chat messages", { + skip_if_not_installed("shinychat") + skip_if_not_installed("htmltools") + + turns <- c( + list(ellmer::UserTurn("How many orders?")), + test_tool_turns("call_measure"), + list( + ellmer::AssistantTurn("6 orders."), + ellmer::UserTurn("Total revenue?") + ), + test_tool_turns("run_sql", id = "c2"), + list(ellmer::AssistantTurn( + "5650.\n\nRevenue excludes tax." + )) + ) + + transcript <- trajectory_transcript(turns) + + expect_equal( + vapply(transcript$messages, function(m) m$role, character(1)), + c("user", "assistant", "user", "assistant") + ) + expect_equal(transcript$count, 2) + + answer <- transcript$messages[[2]]$content + expect_length(answer, 2) + expect_s3_class(answer[[1]], "shinychat_tool_card") + expect_equal(answer[[2]], "6 orders.") + + # The verified-answer pill lands on the first answer; the cited answer + # sends an empty pill whose unverified citations still trigger markup + # cleanup client-side. + expect_length(transcript$pills, 2) + expect_match(transcript$pills[[1]]$html, "commons-answer-pill-trusted") + expect_equal(transcript$pills[[1]]$indexFromEnd, 1) + expect_equal(as.character(transcript$pills[[2]]$html), "") + expect_equal(transcript$pills[[2]]$citations, list(list(verified = FALSE))) + expect_equal(transcript$pills[[2]]$indexFromEnd, 0) +}) + +test_that("trajectory_transcript keeps unanswered questions out of the count", { + skip_if_not_installed("shinychat") + skip_if_not_installed("htmltools") + + turns <- c( + list(ellmer::UserTurn("How many orders?")), + test_tool_turns("call_measure"), + list( + ellmer::AssistantTurn("6 orders."), + ellmer::UserTurn("Total revenue?") + ) + ) + + transcript <- trajectory_transcript(turns) + + expect_equal( + vapply(transcript$messages, function(m) m$role, character(1)), + c("user", "assistant", "user") + ) + expect_equal(transcript$count, 1) + expect_length(transcript$pills, 1) + expect_equal(transcript$pills[[1]]$indexFromEnd, 0) +}) + +test_that("check_trajectories accepts empty trajectories and rejects other shapes", { + expect_no_error(check_trajectories(list())) + expect_snapshot(check_trajectories("nope"), error = TRUE) + expect_snapshot(check_trajectories(list(list())), error = TRUE) +}) + +test_that("the viewer filters conversations and follows selection", { + skip_if_not_installed("shiny") + skip_if_not_installed("bslib") + skip_if_not_installed("shinychat") + skip_if_not_installed("htmltools") + + early <- list(ellmer::UserTurn("One."), ellmer::AssistantTurn("1.")) + attr(early, "last_active") <- as.POSIXct("2026-07-01 09:00:00") + late <- list(ellmer::UserTurn("Two."), ellmer::AssistantTurn("2.")) + attr(late, "last_active") <- as.POSIXct("2026-07-20 09:00:00") + trajectories <- list(conv1 = early, conv2 = late) + summary <- summarize_trajectories(trajectories) + + shiny::testServer(viewer_server(trajectories, summary), { + session$setInputs(window = c(as.Date("2026-07-01"), as.Date("2026-07-31"))) + expect_equal(visible(), c(1, 2)) + + session$setInputs(window = c(as.Date("2026-07-15"), as.Date("2026-07-31"))) + expect_equal(visible(), 2) + + expect_null(selected()) + session$setInputs(conversation_2 = 1) + expect_equal(selected(), 2) + }) +}) From 8ef4287d8f5fcd5654135474c725dc5658e87beb Mon Sep 17 00:00:00 2001 From: skaltman Date: Fri, 31 Jul 2026 11:28:58 -0700 Subject: [PATCH 02/38] Add tests for side conversation filtering and viewer interactions --- tests/testthat/test-view-trajectories.R | 205 ++++++++++++++++++++++-- 1 file changed, 188 insertions(+), 17 deletions(-) diff --git a/tests/testthat/test-view-trajectories.R b/tests/testthat/test-view-trajectories.R index ffe5dc2..de2079c 100644 --- a/tests/testthat/test-view-trajectories.R +++ b/tests/testthat/test-view-trajectories.R @@ -1,10 +1,17 @@ # An exchange fragment: an assistant tool call and its tool-result turn, # named like the tool the viewer derives trust levels from. test_tool_turns <- function(name, id = "c1") { - request <- ellmer::ContentToolRequest(id = id, name = name, arguments = list()) + request <- ellmer::ContentToolRequest( + id = id, + name = name, + arguments = list() + ) list( ellmer::AssistantTurn(list(request)), - ellmer::UserTurn(list(ellmer::ContentToolResult(value = "ok", request = request))) + ellmer::UserTurn(list(ellmer::ContentToolResult( + value = "ok", + request = request + ))) ) } @@ -38,7 +45,9 @@ test_that("trajectory_provenance derives tags from tool calls and citations", { list(ellmer::UserTurn("Total revenue?")), test_tool_turns("call_measure", id = "c1"), test_tool_turns("run_sql", id = "c2"), - list(ellmer::AssistantTurn("5650. Revenue excludes tax.")) + list(ellmer::AssistantTurn( + "5650. Revenue excludes tax." + )) ) expect_equal(trajectory_provenance(mixed)[[1]]$tag, "B") @@ -158,6 +167,10 @@ test_that("trajectory_transcript merges each exchange into chat messages", { vapply(transcript$messages, function(m) m$role, character(1)), c("user", "assistant", "user", "assistant") ) + expect_equal( + vapply(transcript$messages, function(m) m$exchange, integer(1)), + c(1L, 1L, 2L, 2L) + ) expect_equal(transcript$count, 2) answer <- transcript$messages[[2]]$content @@ -166,13 +179,21 @@ test_that("trajectory_transcript merges each exchange into chat messages", { expect_equal(answer[[2]], "6 orders.") # The verified-answer pill lands on the first answer; the cited answer - # sends an empty pill whose unverified citations still trigger markup - # cleanup client-side. + # sends an empty pill whose citations become unverified-but-visible + # footnotes. expect_length(transcript$pills, 2) expect_match(transcript$pills[[1]]$html, "commons-answer-pill-trusted") expect_equal(transcript$pills[[1]]$indexFromEnd, 1) expect_equal(as.character(transcript$pills[[2]]$html), "") - expect_equal(transcript$pills[[2]]$citations, list(list(verified = FALSE))) + expect_equal( + transcript$pills[[2]]$citations, + list(list( + verified = TRUE, + reason = NULL, + quote = "Revenue excludes tax.", + label = "unverified" + )) + ) expect_equal(transcript$pills[[2]]$indexFromEnd, 0) }) @@ -200,6 +221,63 @@ test_that("trajectory_transcript keeps unanswered questions out of the count", { expect_equal(transcript$pills[[1]]$indexFromEnd, 0) }) +test_that("side calls are excluded from the viewer", { + title_call <- list( + ellmer::SystemTurn( + "You title chat conversations. Reply with ONLY a title." + ), + ellmer::UserTurn("user: How many orders? assistant: 6 orders.") + ) + promptless <- list( + ellmer::SystemTurn("Be helpful."), + ellmer::AssistantTurn("An unprompted completion.") + ) + real <- list( + ellmer::SystemTurn("Be helpful."), + ellmer::UserTurn("How many orders?"), + ellmer::AssistantTurn("6 orders.") + ) + + expect_true(is_side_conversation(title_call)) + expect_true(is_side_conversation(promptless)) + expect_false(is_side_conversation(real)) + + trajectories <- list(t = title_call, p = promptless, r = real) + expect_message( + kept <- drop_side_conversations(trajectories), + "Excluding 2 logged calls" + ) + expect_named(kept, "r") +}) + +test_that("summarize_questions flattens exchanges across conversations", { + first <- c( + list(ellmer::UserTurn("How many orders?")), + test_tool_turns("call_measure"), + list( + ellmer::AssistantTurn("6 orders."), + ellmer::UserTurn("Total revenue?") + ), + test_tool_turns("run_sql", id = "c2"), + list(ellmer::AssistantTurn("5650.")) + ) + attr(first, "last_active") <- as.POSIXct("2026-07-22 14:30:00") + trajectories <- list( + conv1 = first, + conv2 = list(ellmer::UserTurn("What does revenue mean?")) + ) + + questions <- summarize_questions(trajectories) + + expect_length(questions, 3) + expect_equal(questions[[2]]$conversation, 1) + expect_equal(questions[[2]]$exchange, 2) + expect_equal(questions[[2]]$snippet, "Total revenue?") + expect_equal(questions[[2]]$tag, "C") + expect_equal(questions[[2]]$last_active, as.POSIXct("2026-07-22 14:30:00")) + expect_true(is.na(questions[[3]]$tag)) +}) + test_that("check_trajectories accepts empty trajectories and rejects other shapes", { expect_no_error(check_trajectories(list())) expect_snapshot(check_trajectories("nope"), error = TRUE) @@ -212,22 +290,115 @@ test_that("the viewer filters conversations and follows selection", { skip_if_not_installed("shinychat") skip_if_not_installed("htmltools") - early <- list(ellmer::UserTurn("One."), ellmer::AssistantTurn("1.")) + early <- c( + list(ellmer::UserTurn("One?")), + test_tool_turns("call_measure"), + list(ellmer::AssistantTurn("1.")) + ) attr(early, "last_active") <- as.POSIXct("2026-07-01 09:00:00") - late <- list(ellmer::UserTurn("Two."), ellmer::AssistantTurn("2.")) + late <- c( + list(ellmer::UserTurn("Two?")), + test_tool_turns("run_sql"), + list(ellmer::AssistantTurn("2.")) + ) attr(late, "last_active") <- as.POSIXct("2026-07-20 09:00:00") trajectories <- list(conv1 = early, conv2 = late) summary <- summarize_trajectories(trajectories) + questions <- summarize_questions(trajectories) + review_file <- withr::local_tempfile(fileext = ".jsonl") + + shiny::testServer( + viewer_server(trajectories, summary, questions, review_file), + { + session$setInputs( + group_by = "conversation", + trust = "all", + window = c(as.Date("2026-07-01"), as.Date("2026-07-31")) + ) + expect_equal(visible_conversations(), c(1, 2)) + expect_equal(visible_questions(), c(1, 2)) + + session$setInputs( + window = c(as.Date("2026-07-15"), as.Date("2026-07-31")) + ) + expect_equal(visible_conversations(), 2) + + session$setInputs( + window = c(as.Date("2026-07-01"), as.Date("2026-07-31")), + trust = "C" + ) + expect_equal(visible_conversations(), 2) + expect_equal(visible_questions(), 2) + + expect_null(selected()) + session$setInputs(entry_2_1 = 1) + expect_equal(selected(), list(conversation = 2, exchange = 1)) + expect_equal(review_target(), selected()) + session$setInputs(entry_1 = 1) + expect_equal(selected(), list(conversation = 1)) + expect_null(review_target()) + session$setInputs(exchange_select = list(exchange = 1, nonce = 1)) + expect_equal(review_target(), list(conversation = 1, exchange = 1L)) + + # Clicking the selected exchange again deselects it. + session$setInputs(exchange_select = list(nonce = 2)) + expect_null(review_target()) + + # Moving to another entry drops the previous exchange selection. + session$setInputs(exchange_select = list(exchange = 1, nonce = 3)) + expect_equal(review_target(), list(conversation = 1, exchange = 1L)) + session$setInputs(entry_2 = 1) + expect_null(review_target()) + } + ) +}) + +test_that("flags and notes append to and restore from the review file", { + skip_if_not_installed("shiny") + skip_if_not_installed("bslib") + skip_if_not_installed("shinychat") + skip_if_not_installed("htmltools") - shiny::testServer(viewer_server(trajectories, summary), { - session$setInputs(window = c(as.Date("2026-07-01"), as.Date("2026-07-31"))) - expect_equal(visible(), c(1, 2)) + turns <- list(ellmer::UserTurn("One?"), ellmer::AssistantTurn("1.")) + trajectories <- list(conv1 = turns) + summary <- summarize_trajectories(trajectories) + questions <- summarize_questions(trajectories) + review_file <- withr::local_tempfile(fileext = ".jsonl") + + shiny::testServer( + viewer_server(trajectories, summary, questions, review_file), + { + session$setInputs(group_by = "conversation", trust = "all", entry_1 = 1) + session$setInputs(flag_toggle = 1) + expect_equal(flags(), "conv1") + + session$setInputs(exchange_select = list(exchange = 1, nonce = 1)) + session$setInputs(flag_toggle = 2) + expect_equal(flags(), c("conv1", "conv1#1")) + + session$setInputs(review_note = "Wrong join, should use orders.") + session$setInputs(save_note = 1) + expect_length(notes(), 1) + expect_equal(notes()[[1]]$note, "Wrong join, should use orders.") + expect_equal( + notes_for_selection(notes(), review_target(), summary), + notes() + ) + } + ) - session$setInputs(window = c(as.Date("2026-07-15"), as.Date("2026-07-31"))) - expect_equal(visible(), 2) + records <- lapply(readLines(review_file), jsonlite::fromJSON) + expect_equal( + vapply(records, function(r) r$action, character(1)), + c("flag", "flag", "note") + ) + expect_equal(records[[2]]$conversation, "conv1") + expect_equal(records[[2]]$exchange, 1) + expect_equal(records[[3]]$note, "Wrong join, should use orders.") - expect_null(selected()) - session$setInputs(conversation_2 = 1) - expect_equal(selected(), 2) - }) + expect_equal(read_review_flags(review_file), c("conv1", "conv1#1")) + expect_equal( + vapply(read_review_notes(review_file), `[[`, character(1), "note"), + "Wrong join, should use orders." + ) }) From b18623eb605a0bdb0218c6690ba44e5b50e07a86 Mon Sep 17 00:00:00 2001 From: skaltman Date: Fri, 31 Jul 2026 11:30:46 -0700 Subject: [PATCH 03/38] Add review functionality to trajectory viewer with flagging and annotations --- DESCRIPTION | 1 + R/view-trajectories.R | 760 ++++++++++++++++++--- inst/www/commons-viewer/commons-viewer.css | 218 +++++- inst/www/commons-viewer/commons-viewer.js | 155 +++++ man/view_trajectories.Rd | 31 +- 5 files changed, 1074 insertions(+), 91 deletions(-) create mode 100644 inst/www/commons-viewer/commons-viewer.js diff --git a/DESCRIPTION b/DESCRIPTION index e9c9b37..b9c7359 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -50,6 +50,7 @@ Suggests: rmarkdown, shiny, shinychat (> 0.4.0), + shinyWidgets, testthat (>= 3.0.0), vitals, withr, diff --git a/R/view-trajectories.R b/R/view-trajectories.R index 6405e5b..3299d85 100644 --- a/R/view-trajectories.R +++ b/R/view-trajectories.R @@ -3,20 +3,35 @@ #' @description #' `view_trajectories()` launches a Shiny app for browsing conversation #' trajectories read with [read_trajectories()]. The app shows the rate of -#' each trust level across conversations, a conversation list that can be -#' filtered by date, and a read-only transcript of each conversation with the -#' provenance pills the commons chat UI would show. +#' each trust level across conversations, a list of conversations or of +#' individual questions—filterable by date and trust level—and a transcript +#' of each with the provenance pills the commons chat UI would show. +#' +#' Transcripts are reviewable rather than live: conversations and questions +#' can be flagged for review, and selecting a question-and-answer exchange +#' allows it to be annotated with notes. Both land in `review_file`, one JSON +#' record per line, and are restored when the viewer reopens. #' #' Trajectories carry no record of how each answer was tagged when it was #' produced, so the viewer derives trust levels from the tool calls in the #' trajectory: answers backed only by governed tools (`call_measure`, #' `call_metrics`) are verified, and answers that used fallback tools #' (`run_sql`, `run_r`) count as cited when they contain citation markup and -#' untrusted when they don't. Citation quotes are not re-verified against the -#' agent's context. +#' untrusted when they don't. A cited answer's quotes render as footnotes so +#' they can be reviewed, but they are not re-verified against the agent's +#' context: footnotes name no source and are attributed "unverified". +#' +#' Logged calls that aren't part of the agent's question-and-answer record— +#' shinychat's conversation-title generation, and completions with no user +#' turn—are excluded from the viewer. #' #' @param trajectories A named list of conversations, as returned by #' [read_trajectories()]. +#' @param review_file Path of the JSONL file that review actions append to: +#' flags, unflags, and feedback notes, each with a timestamp, the +#' conversation id, and (for questions) the exchange number. Created on +#' first use; flags and notes recorded here are restored when the viewer +#' reopens. #' #' @return A [shiny::shinyApp()] object. Calling `view_trajectories()` at the #' console launches the viewer; the result can also be served as the last @@ -29,15 +44,24 @@ #' view_trajectories(read_trajectories(from = "2026-07-01")) #' } #' @export -view_trajectories <- function(trajectories = read_trajectories()) { +view_trajectories <- function( + trajectories = read_trajectories(), + review_file = "commons-review.jsonl" +) { check_viewer_packages() check_trajectories(trajectories) + rlang::check_string(review_file) + trajectories <- drop_side_conversations(trajectories) summary <- summarize_trajectories(trajectories) - shiny::shinyApp(viewer_ui(summary), viewer_server(trajectories, summary)) + questions <- summarize_questions(trajectories) + shiny::shinyApp( + viewer_ui(summary), + viewer_server(trajectories, summary, questions, review_file) + ) } check_viewer_packages <- function(call = rlang::caller_env()) { - pkgs <- c("bslib", "htmltools", "shiny", "shinychat") + pkgs <- c("bslib", "htmltools", "shiny", "shinychat", "shinyWidgets") missing <- pkgs[!vapply(pkgs, requireNamespace, logical(1), quietly = TRUE)] if (length(missing)) { @@ -65,6 +89,33 @@ check_trajectories <- function(trajectories, call = rlang::caller_env()) { } } +drop_side_conversations <- function(trajectories) { + side <- vapply(trajectories, is_side_conversation, logical(1)) + if (any(side)) { + cli::cli_inform( + "Excluding {sum(side)} logged call{?s} that {?isn't/aren't} part of the + agent's Q&A record (e.g. conversation-title generation)." + ) + } + trajectories[!side] +} + +# shinychat's conversation-title generation rides a clone of the agent's own +# client, so its calls land in the trace store looking like one-question +# conversations; match its system prompt (shinychat's TITLE_SYSTEM_PROMPT) +# by prefix. Completions with no plain user turn have no question to review +# and are side calls of one kind or another. +shinychat_title_prompt <- "You title chat conversations." + +is_side_conversation <- function(turns) { + if (length(split_exchanges(turns)) == 0) { + return(TRUE) + } + system <- turns[[1]] + identical(system@role, "system") && + startsWith(system@text, shinychat_title_prompt) +} + # Summaries --------------------------------------------------------------- # One record per conversation, in input order. A plain list of records @@ -85,6 +136,28 @@ conversation_record <- function(id, turns) { ) } +# One record per question -> answer exchange across all conversations, in +# conversation order. +summarize_questions <- function(trajectories) { + records <- list() + for (i in rlang::seq2(1, length(trajectories))) { + turns <- trajectories[[i]] + exchanges <- split_exchanges(turns) + provenance <- lapply(exchanges, exchange_provenance) + for (j in rlang::seq2(1, length(exchanges))) { + records[[length(records) + 1]] <- list( + conversation = i, + conversation_id = names(trajectories)[[i]], + exchange = j, + snippet = question_snippet(exchanges[[j]]), + tag = provenance[[j]]$tag, + last_active = attr(turns, "last_active") %||% as.POSIXct(NA) + ) + } + } + records +} + # The viewer re-derives what runtime tagging stores in extra$commons_tag -- # dropped by the OTLP round trip -- from the tool-call names that survive # it. B vs C is decided by citation *presence*: with no agent there is no @@ -133,11 +206,15 @@ first_user_snippet <- function(exchanges, max_chars = 80) { if (length(exchanges) == 0) { return("") } - text <- trimws(gsub("\\s+", " ", exchanges[[1]][[1]]@text)) + question_snippet(exchanges[[1]], max_chars) +} + +question_snippet <- function(exchange, max_chars = 80) { + text <- trimws(gsub("\\s+", " ", exchange[[1]]@text)) if (nchar(text) <= max_chars) { return(text) } - paste0(substr(text, 1, max_chars - 1), "…") + paste0(substr(text, 1, max_chars - 1), "\u2026") } # Exchange-level counts of each trust level across a set of conversations' @@ -164,16 +241,20 @@ hit_rate <- function(tag_sets) { # Tool requests are dropped, mirroring shinychat's own transcript restore # (each result card carries its request). `count` and `indexFromEnd` index # assistant messages, which is what the seed handler counts. -trajectory_transcript <- function(turns) { +trajectory_transcript <- function(turns, exchange_numbers = NULL) { exchanges <- split_exchanges(turns) + exchange_numbers <- exchange_numbers %||% seq_along(exchanges) messages <- list() pills <- list() n_assistant <- 0L - for (exchange in exchanges) { + for (i in seq_along(exchanges)) { + exchange <- exchanges[[i]] + exchange_number <- exchange_numbers[[i]] messages[[length(messages) + 1]] <- list( role = "user", - content = exchange[[1]]@text + content = exchange[[1]]@text, + exchange = exchange_number ) chunks <- exchange_answer_chunks(exchange[-1]) if (length(chunks) == 0) { @@ -182,7 +263,8 @@ trajectory_transcript <- function(turns) { n_assistant <- n_assistant + 1L messages[[length(messages) + 1]] <- list( role = "assistant", - content = chunks + content = chunks, + exchange = exchange_number ) pill <- viewer_pill(exchange_provenance(exchange), n_assistant) if (!is.null(pill)) { @@ -198,19 +280,37 @@ trajectory_transcript <- function(turns) { # Exchanges whose tag is NA and whose answer attempted no citations need # neither a pill nor citation cleanup. Cited ("B") answers send an empty -# pill: the client still strips their markup before finding no -# pill to place. All citations go over unverified, so none become footnotes. +# pill and their citations render as numbered footnotes, so a reviewer can +# read what the answer quoted; citations on other answers are stripped, as +# at runtime. viewer_pill <- function(provenance, assistant_index) { if (is.na(provenance$tag) && length(provenance$citations) == 0) { return(NULL) } + citations <- if (identical(provenance$tag, "B")) { + lapply(provenance$citations, viewer_citation) + } else { + lapply(provenance$citations, function(x) list(verified = FALSE)) + } list( html = htmltools::renderTags(commons_answer_pill(provenance$tag))$html, - citations = lapply(provenance$citations, function(x) list(verified = FALSE)), + citations = citations, indexFromEnd = assistant_index ) } +# Mirrors citations_payload() in chat.R, but for quotes the viewer can't +# check against a corpus: the footnote attributes the quote to "unverified" +# rather than naming a source. +viewer_citation <- function(citation) { + list( + verified = TRUE, + reason = if (!is.na(citation$reason)) citation$reason, + quote = normalize_citation(citation$quote), + label = "unverified" + ) +} + exchange_answer_chunks <- function(turns) { chunks <- list() for (turn in turns) { @@ -227,11 +327,22 @@ exchange_answer_chunks <- function(turns) { # Replays a conversation into a bound chat element. Assistant messages # stream as chunks -- the mode the pill-seed handler was designed around -- # and the seed is sent last so pills land once the transcript settles. -restore_transcript <- function(session, id, turns) { - transcript <- trajectory_transcript(turns) +restore_transcript <- function( + session, + id, + turns, + exchange_numbers = NULL, + selected_exchange = NULL +) { + transcript <- trajectory_transcript(turns, exchange_numbers) for (message in transcript$messages) { if (identical(message$role, "user")) { - shinychat::chat_append_message(id, message, chunk = FALSE, session = session) + shinychat::chat_append_message( + id, + message, + chunk = FALSE, + session = session + ) next } shinychat::chat_append_message( @@ -261,6 +372,21 @@ restore_transcript <- function(session, id, turns) { list(id = id, count = transcript$count, pills = transcript$pills) ) } + if (length(transcript$messages) > 0) { + session$sendCustomMessage( + "commonsViewerExchangeSeed", + list( + id = id, + count = length(transcript$messages), + exchanges = vapply( + transcript$messages, + function(message) message$exchange, + integer(1) + ), + selected = selected_exchange + ) + ) + } } # App --------------------------------------------------------------------- @@ -269,90 +395,274 @@ viewer_ui <- function(summary) { dates <- viewer_date_range(summary) htmltools::attachDependencies( bslib::page_sidebar( - title = "Conversations", + title = "Trajectory reviewer", sidebar = bslib::sidebar( width = 380, - shiny::dateRangeInput( + class = "commons-viewer-sidebar", + # The navset is purely a switcher: its panels are empty, and the + # selected value arrives as input$group_by. + bslib::navset_underline( + id = "group_by", + bslib::nav_panel("Conversations", value = "conversation"), + bslib::nav_panel("Questions", value = "question") + ), + shinyWidgets::airDatepickerInput( "window", - "Active between", - start = dates$min, - end = dates$max, - min = dates$min, - max = dates$max + "Dates", + range = TRUE, + value = c(dates$min, dates$max), + minDate = dates$min, + maxDate = dates$max, + dateFormat = "MMM d, yyyy", + update_on = "close", + addon = "none" + ), + shiny::selectInput( + "trust", + "Trust Level", + trust_choices("conversation") ), - shiny::uiOutput("conversations") + shiny::uiOutput("entries") ), shiny::uiOutput("hit_rate"), bslib::card( fill = TRUE, class = "commons-viewer-transcript", - shiny::uiOutput("transcript", fill = TRUE) + htmltools::div( + class = "commons-viewer-workspace", + htmltools::div( + class = "commons-viewer-transcript-pane", + shiny::uiOutput("transcript", fill = TRUE) + ), + htmltools::div( + class = "commons-viewer-review-pane", + shiny::uiOutput("review_bar") + ) + ) ) ), list(commons_chat_dependency(), commons_viewer_dependency()) ) } -viewer_server <- function(trajectories, summary) { +# In conversation view the trust filter keeps conversations *containing* a +# matching answer; the option labels say so. +trust_choices <- function(group_by) { + if (identical(group_by, "question")) { + c( + "All answers" = "all", + "Verified" = "A", + "Cited" = "B", + "Untrusted" = "C", + "No data tool" = "none" + ) + } else { + c( + "All conversations" = "all", + "Has a verified answer" = "A", + "Has a cited answer" = "B", + "Has an untrusted answer" = "C", + "Has an answer with no data tool" = "none" + ) + } +} + +viewer_server <- function(trajectories, summary, questions, review_file) { function(input, output, session) { selected <- shiny::reactiveVal(NULL) + review_target <- shiny::reactiveVal(NULL) + review_records <- read_review_records(review_file) + flags <- shiny::reactiveVal(review_flags(review_records)) + notes <- shiny::reactiveVal(review_notes(review_records)) + + shiny::observeEvent(input$group_by, { + shiny::updateSelectInput( + session, + "trust", + choices = trust_choices(input$group_by), + selected = input$trust + ) + }) - visible <- shiny::reactive({ - window <- input$window + visible_conversations <- shiny::reactive({ Filter( - function(i) in_window(summary[[i]], window), + function(i) { + conversation_visible(summary[[i]], input$window, input$trust) + }, seq_along(summary) ) }) + visible_questions <- shiny::reactive({ + Filter( + function(k) question_visible(questions[[k]], input$window, input$trust), + seq_along(questions) + ) + }) + + # The hit rate reflects the date window but not the trust filter: it is + # the trust distribution the filter slices. output$hit_rate <- shiny::renderUI({ - hit_rate_boxes(hit_rate(lapply(visible(), function(i) summary[[i]]$tags))) + in_dates <- Filter( + function(i) in_window(summary[[i]], input$window), + seq_along(summary) + ) + hit_rate_boxes(hit_rate(lapply(in_dates, function(i) summary[[i]]$tags))) }) - output$conversations <- shiny::renderUI({ - indices <- visible() - if (length(indices) == 0) { - return(viewer_empty_note(if (length(summary) == 0) { - "No conversations to view." - } else { - "No conversations in this date range." - })) + output$entries <- shiny::renderUI({ + entries <- if (identical(input$group_by, "question")) { + lapply( + visible_questions(), + function(k) question_entry(questions[[k]], selected(), flags()) + ) + } else { + lapply( + visible_conversations(), + function(i) conversation_entry(i, summary[[i]], selected(), flags()) + ) } - lapply(indices, function(i) { - conversation_entry(i, summary[[i]], selected = identical(selected(), i)) - }) + if (length(entries) == 0) { + return(viewer_empty_note( + if (length(summary) == 0) { + "No conversations to view." + } else { + "Nothing matches these filters." + } + )) + } + entries }) - # Entry ids index into `trajectories`, which never changes, so observers - # registered once stay valid as the date filter re-renders the list. - for (i in seq_along(trajectories)) { + # Entry and transcript ids index into `trajectories`, which never + # changes, so observers registered once stay valid as the filters + # re-render the list. + all_keys <- c( + lapply(seq_along(trajectories), function(i) list(conversation = i)), + lapply(questions, function(record) record[c("conversation", "exchange")]) + ) + for (key in all_keys) { local({ - index <- i - shiny::observeEvent(input[[paste0("conversation_", index)]], { - selected(index) + k <- key + shiny::observeEvent(input[[entry_link_id(k)]], { + selected(k) + review_target(if (is.null(k$exchange)) NULL else k) }) }) } + output$review_bar <- shiny::renderUI({ + navigation <- selected() + if (is.null(navigation)) { + return(NULL) + } + key <- review_target() + flagged <- selection_review_key(key %||% navigation, summary) %in% flags() + if (is.null(key)) { + return(review_bar_prompt(flagged)) + } + review_bar_notes(key, flagged, notes_for_selection(notes(), key, summary)) + }) + + shiny::observeEvent(input$flag_toggle, { + key <- review_target() %||% selected() + review <- selection_review_key(key, summary) + flagged <- review %in% flags() + append_review_record( + review_file, + list( + time = format(Sys.time(), "%Y-%m-%dT%H:%M:%S%z"), + conversation = summary[[key$conversation]]$id, + exchange = key$exchange, + action = if (flagged) "unflag" else "flag" + ) + ) + flags(if (flagged) setdiff(flags(), review) else union(flags(), review)) + }) + + shiny::observeEvent(input$exchange_select, { + navigation <- selected() + if (is.null(navigation)) { + return() + } + # A null exchange is the client deselecting (clicking the selected + # exchange again). + exchange <- as.integer(input$exchange_select$exchange) + if (length(exchange) != 1 || is.na(exchange)) { + review_target(NULL) + return() + } + if ( + !exchange %in% + seq_along(split_exchanges(trajectories[[navigation$conversation]])) + ) { + return() + } + review_target(list( + conversation = navigation$conversation, + exchange = exchange + )) + }) + + # The transcript highlight follows review_target wherever it changes -- + # including deselection by re-clicking the current entry -- so the + # client never shows a selection the review pane has dropped. Echoing a + # client-initiated selection back is harmless: re-applying it sends no + # input, and a message racing a fresh transcript finds no element and + # dies quietly (the exchange seed carries that state instead). + shiny::observeEvent(review_target(), ignoreNULL = FALSE, ignoreInit = TRUE, { + key <- selected() + if (is.null(key)) { + return() + } + target <- review_target() + session$sendCustomMessage( + "commonsViewerExchangeSelect", + list( + id = transcript_id(key), + exchange = if (!is.null(target)) target$exchange + ) + ) + }) + + shiny::observeEvent(input$save_note, { + key <- review_target() + note <- trimws(input$review_note %||% "") + if (is.null(key) || !nzchar(note)) { + return() + } + record <- review_note_record(summary, key, note) + append_review_record(review_file, record) + notes(c(notes(), list(record))) + }) + # A fresh chat element per selection: a superseded pill-seed timer from a - # fast conversation switch then targets a defunct element id and dies + # fast selection switch then targets a defunct element id and dies # harmlessly instead of racing the new transcript. output$transcript <- shiny::renderUI({ - i <- selected() - if (is.null(i)) { - return(viewer_empty_note("Select a conversation to view its transcript.")) + key <- selected() + if (is.null(key)) { + return(viewer_empty_note( + "Select a conversation to view its transcript." + )) } - commons_ui(paste0("transcript_", i), height = "100%") + commons_ui(transcript_id(key), height = "100%") }) # onFlushed fires after the flush that delivers the new chat element, so # it is bound client-side before the replayed messages arrive -- the same # mechanism commons_server() uses to seed pills. shiny::observeEvent(selected(), { - i <- selected() + key <- selected() session$onFlushed( function() { - restore_transcript(session, paste0("transcript_", i), trajectories[[i]]) + restore_transcript( + session, + transcript_id(key), + selected_turns(trajectories, key), + exchange_numbers = key$exchange, + selected_exchange = key$exchange + ) }, once = TRUE ) @@ -360,6 +670,234 @@ viewer_server <- function(trajectories, summary) { } } +conversation_visible <- function(record, window, trust) { + in_window(record, window) && + (identical(trust, "all") || any(tag_matches(record$tags, trust))) +} + +question_visible <- function(record, window, trust) { + in_window(record, window) && tag_matches(record$tag, trust) +} + +tag_matches <- function(tags, trust) { + switch( + trust, + all = rep(TRUE, length(tags)), + none = is.na(tags), + tags %in% trust + ) +} + +# A question selection restores only its own exchange; a conversation +# selection restores the whole transcript. +selected_turns <- function(trajectories, key) { + turns <- trajectories[[key$conversation]] + if (is.null(key$exchange)) { + return(turns) + } + split_exchanges(turns)[[key$exchange]] +} + +entry_link_id <- function(key) { + paste(c("entry", key$conversation, key$exchange), collapse = "_") +} + +transcript_id <- function(key) { + paste(c("transcript", key$conversation, key$exchange), collapse = "_") +} + +# Review ------------------------------------------------------------------ + +# The review pane before an exchange is chosen; the flag applies to the +# whole conversation. +review_bar_prompt <- function(flagged) { + htmltools::div( + class = "commons-viewer-review", + htmltools::div( + class = "commons-viewer-review-bar", + htmltools::tags$strong("Review Notes"), + flag_button(flagged, TRUE) + ), + htmltools::div( + class = "commons-viewer-review-prompt", + "Select a question or answer in the transcript to add a note." + ) + ) +} + +review_bar_notes <- function(key, flagged, notes) { + htmltools::div( + class = "commons-viewer-review", + htmltools::div( + class = "commons-viewer-review-bar", + htmltools::tags$strong( + sprintf("Review Notes for Question %d", key$exchange) + ), + flag_button(flagged, FALSE) + ), + review_note_list(notes), + htmltools::div( + class = "commons-viewer-note-compose", + shiny::textAreaInput( + "review_note", + NULL, + placeholder = "Add a note about this exchange", + rows = 2, + width = "100%" + ), + shiny::actionButton( + "save_note", + "Add note", + class = "btn-sm btn-outline-secondary" + ) + ) + ) +} + +review_note_record <- function(summary, key, note) { + list( + time = format(Sys.time(), "%Y-%m-%dT%H:%M:%S%z"), + conversation = summary[[key$conversation]]$id, + exchange = key$exchange, + action = "note", + note = note + ) +} + +review_note_list <- function(notes) { + if (length(notes) == 0) { + return(NULL) + } + htmltools::div( + class = "commons-viewer-notes", + lapply(notes, function(note) { + htmltools::div( + class = "commons-viewer-note", + htmltools::div(class = "commons-viewer-note-text", note$note), + note_date(note) + ) + }) + ) +} + +# Notes date themselves the way list entries do; a record whose time doesn't +# parse (or predates timestamps) just goes undated. +note_date <- function(note) { + time <- strptime(note$time %||% "", "%Y-%m-%dT%H:%M:%S%z") + if (is.na(time)) { + return(NULL) + } + htmltools::div( + class = "commons-viewer-note-meta", + format(time, "%b %e, %Y") + ) +} + +append_review_record <- function(file, record) { + line <- jsonlite::toJSON(drop_nulls(record), auto_unbox = TRUE) + cat(line, "\n", file = file, sep = "", append = TRUE) +} + +read_review_flags <- function(file) { + review_flags(read_review_records(file)) +} + +read_review_notes <- function(file) { + review_notes(read_review_records(file)) +} + +read_review_records <- function(file) { + if (!file.exists(file)) { + return(list()) + } + drop_nulls(lapply(readLines(file, warn = FALSE), function(line) { + tryCatch(jsonlite::fromJSON(line), error = function(err) NULL) + })) +} + +# Flags persist as flag/unflag events; the latest event per key wins. +review_flags <- function(records) { + flags <- character() + for (record in records) { + if (is.null(record$action)) { + next + } + key <- review_key(record$conversation, record$exchange) + if (identical(record$action, "flag")) { + flags <- union(flags, key) + } else if (identical(record$action, "unflag")) { + flags <- setdiff(flags, key) + } + } + flags +} + +review_notes <- function(records) { + Filter( + function(record) { + identical(record$action, "note") && + is.character(record$note) && + length(record$note) == 1 + }, + records + ) +} + +notes_for_selection <- function(notes, key, summary) { + selection <- selection_review_key(key, summary) + Filter( + function(note) review_key(note$conversation, note$exchange) == selection, + notes + ) +} + +# Flag keys use the conversation id (not its position), so they stay stable +# across differently filtered reads of the same trace store. +review_key <- function(id, exchange = NULL) { + paste(c(id, exchange), collapse = "#") +} + +selection_review_key <- function(key, summary) { + review_key(summary[[key$conversation]]$id, key$exchange) +} + +# An icon-only toggle: the flag reads gray until flagged, then wears the +# same orange as the list markers. State also rides in the tooltip and +# aria-pressed, so it isn't conveyed by color alone. +flag_button <- function(flagged, whole_conversation) { + what <- if (whole_conversation) "conversation" else "question" + title <- if (flagged) { + "Flagged for review \u2014 click to unflag" + } else { + sprintf("Flag this %s for review", what) + } + shiny::actionButton( + "flag_toggle", + label = "\u2691", + class = if (flagged) { + "commons-viewer-flag-button commons-viewer-flag-button-on" + } else { + "commons-viewer-flag-button" + }, + title = title, + `aria-label` = title, + `aria-pressed` = if (flagged) "true" else "false" + ) +} + +flag_marker <- function(flagged) { + if (!flagged) { + return(NULL) + } + htmltools::tags$span( + class = "commons-viewer-flag", + title = "Flagged for review", + "\u2691" + ) +} + +# Entries ----------------------------------------------------------------- + viewer_levels <- c( A = "Verified", B = "Cited", @@ -368,66 +906,123 @@ viewer_levels <- c( ) hit_rate_boxes <- function(rate) { - boxes <- lapply(names(viewer_levels), function(key) { - bslib::value_box( - title = viewer_levels[[key]], - value = rate_percent(rate$counts[[key]], rate$n), - htmltools::p(sprintf("%d of %d answers", rate$counts[[key]], rate$n)) - ) - }) + boxes <- lapply(names(viewer_levels), function(key) level_box(key, rate)) do.call(bslib::layout_columns, c(boxes, list(fill = FALSE))) } +# The Verified box wears the verified-answer pill's palette (see +# .commons-answer-pill-trusted in commons-chat.css) and its shield. +level_box <- function(key, rate) { + verified <- identical(key, "A") + bslib::value_box( + title = viewer_levels[[key]], + value = rate_percent(rate$counts[[key]], rate$n), + htmltools::p(sprintf("%d of %d answers", rate$counts[[key]], rate$n)), + showcase = if (verified) verified_shield(), + theme = if (verified) { + bslib::value_box_theme(bg = "#f2fbf5", fg = "#286144") + }, + class = if (verified) "commons-viewer-value-verified" + ) +} + +# The same shield the verified-answer pill carries, inlined so the value +# box's showcase can size it. +verified_shield <- function() { + path <- system.file("figs", "trusted-icon.svg", package = "commons") + svg <- paste(readLines(path, warn = FALSE), collapse = "\n") + htmltools::HTML(sub("^\\s*<\\?xml[^>]*\\?>\\s*", "", svg)) +} + rate_percent <- function(count, n) { if (n == 0) { - return("—") + return("\u2014") } sprintf("%.0f%%", 100 * count / n) } -conversation_entry <- function(index, record, selected = FALSE) { +conversation_entry <- function( + index, + record, + selected = NULL, + flags = character() +) { + key <- list(conversation = index) pills <- lapply(intersect(c("A", "C"), record$tags), commons_answer_pill) shiny::actionLink( - paste0("conversation_", index), - class = if (selected) { - "commons-viewer-entry commons-viewer-entry-selected" - } else { - "commons-viewer-entry" - }, + entry_link_id(key), + class = entry_class(identical(selected, key)), label = htmltools::tagList( htmltools::div(class = "commons-viewer-entry-snippet", record$snippet), htmltools::div( class = "commons-viewer-entry-meta", - htmltools::tags$span(entry_meta(record)), + flag_marker(review_key(record$id) %in% flags), + htmltools::tags$span(conversation_meta(record)), pills ) ) ) } -entry_meta <- function(record) { +question_entry <- function(record, selected = NULL, flags = character()) { + key <- record[c("conversation", "exchange")] + flagged <- review_key(record$conversation_id, record$exchange) %in% flags + shiny::actionLink( + entry_link_id(key), + class = entry_class(identical(selected, key)), + label = htmltools::tagList( + htmltools::div(class = "commons-viewer-entry-snippet", record$snippet), + htmltools::div( + class = "commons-viewer-entry-meta", + flag_marker(flagged), + htmltools::tags$span(entry_date(record)), + commons_answer_pill(record$tag) + ) + ) + ) +} + +entry_class <- function(selected) { + if (selected) { + "commons-viewer-entry commons-viewer-entry-selected" + } else { + "commons-viewer-entry" + } +} + +conversation_meta <- function(record) { turns <- sprintf( "%d %s", record$n_user_turns, if (record$n_user_turns == 1) "turn" else "turns" ) + date <- entry_date(record) + if (is.null(date)) { + return(turns) + } + sprintf("%s \u00b7 %s", turns, date) +} + +entry_date <- function(record) { date <- local_date(record$last_active) if (is.na(date)) { - return(turns) + return(NULL) } - sprintf("%s · %s", turns, format(date, "%b %e, %Y")) + format(date, "%b %e, %Y") } viewer_empty_note <- function(text) { htmltools::div(class = "commons-viewer-empty", text) } -# Conversations without a timestamp always pass the date filter. +# Conversations without a timestamp always pass the date filter, as does +# everything while the picker holds less than a complete range. in_window <- function(record, window) { + if (length(window) < 2) { + return(TRUE) + } date <- local_date(record$last_active) - is.na(date) || - ((is.na(window[[1]]) || date >= window[[1]]) && - (is.na(window[[2]]) || date <= window[[2]])) + is.na(date) || (date >= window[[1]] && date <= window[[2]]) } # The date filter's bounds; when no conversation carries a timestamp, both @@ -462,6 +1057,7 @@ commons_viewer_dependency <- function() { name = "commons-viewer", version = paste0("0.0.0.9000.", as.integer(stamp)), src = c(file = src), - stylesheet = "commons-viewer.css" + stylesheet = "commons-viewer.css", + script = "commons-viewer.js" ) } diff --git a/inst/www/commons-viewer/commons-viewer.css b/inst/www/commons-viewer/commons-viewer.css index 7d613b9..2829f38 100644 --- a/inst/www/commons-viewer/commons-viewer.css +++ b/inst/www/commons-viewer/commons-viewer.css @@ -1,6 +1,218 @@ -/* Read-only transcripts: the input box is hidden rather than removed so a - * future feedback chat can bring it back by deleting this rule. */ -.commons-viewer-transcript shiny-chat-input { +/* shinychat's stylesheet arrives with the dynamically rendered chat, after + * this one, so overrides of its rules carry an extra class of specificity + * to win regardless of load order. */ + +/* shinychat falls back to a robot icon on assistant messages when no + * icon_assistant is configured; commons chats carry no icon. */ +.commons-viewer-transcript .shiny-chat-message .message-icon { + display: none; +} + +/* Review notes use their own composer rather than appearing as chat turns. */ +.commons-viewer-transcript .shiny-chat-input { + display: none; +} + +/* ---- Hit rate ----------------------------------------------------------- */ + +/* Border to match the verified-answer pill; bg/fg come from the box theme. */ +.commons-viewer-value-verified { + border: 1px solid #cfeedd; +} + +.commons-viewer-value-verified .value-box-showcase svg { + height: 2.5rem; + width: 2.5rem; +} + +/* ---- Review ------------------------------------------------------------ */ + +.commons-viewer-workspace { + display: grid; + flex: 1 1 auto; + grid-template-columns: minmax(0, 1fr) minmax(16rem, 22rem); + height: 100%; + min-height: 0; +} + +.commons-viewer-transcript-pane { + display: flex; + min-height: 0; + min-width: 0; +} + +.commons-viewer-transcript-pane > .shiny-html-output { + flex: 1 1 auto; + min-height: 0; +} + +.commons-viewer-review-pane { + border-left: 1px solid var(--bs-border-color, #dee2e6); + min-width: 0; + overflow-y: auto; +} + +.commons-viewer-review { + padding: 0.75rem 1rem; +} + +.commons-viewer-review-bar { + align-items: center; + display: flex; + justify-content: space-between; +} + +/* Section labels match the sidebar's Dates / Trust Level captions. */ +.commons-viewer-review-bar strong { + color: var(--bs-secondary-color, #6c757d); + font-size: 0.8125rem; + font-weight: 600; +} + +.commons-viewer-review-prompt { + color: var(--bs-secondary-color, #6c757d); + font-size: 0.875rem; + margin-top: 0.5rem; +} + +.commons-viewer-notes { + display: grid; + gap: 0.375rem; + margin-top: 0.625rem; +} + +.commons-viewer-note { + background: var(--bs-secondary-bg, #f1f3f5); + border-radius: 0.5rem; + font-size: 0.875rem; + padding: 0.5rem 0.625rem; +} + +.commons-viewer-note-text { + white-space: pre-wrap; +} + +.commons-viewer-note-meta { + color: var(--bs-secondary-color, #6c757d); + font-size: 0.75rem; + margin-top: 0.125rem; +} + +.commons-viewer-note-compose { + align-items: flex-end; + display: flex; + gap: 0.5rem; + margin-top: 0.625rem; +} + +.commons-viewer-note-compose .shiny-input-container { + margin: 0; + width: 100%; +} + +.commons-viewer-note-compose textarea { + resize: vertical; +} + +.commons-viewer-note-compose .btn { + flex: 0 0 auto; +} + +.commons-viewer-flag-button, +.commons-viewer-flag-button:hover, +.commons-viewer-flag-button:focus, +.commons-viewer-flag-button:active { + background: transparent; + border: none; + color: var(--bs-secondary-color, #6c757d); + font-size: 1.25rem; + line-height: 1; + padding: 0.25rem 0.5rem; +} + +.commons-viewer-flag-button:hover, +.commons-viewer-flag-button-on, +.commons-viewer-flag-button-on:hover, +.commons-viewer-flag-button-on:focus, +.commons-viewer-flag-button-on:active { + color: #b54708; +} + +.commons-viewer-flag { + color: #b54708; +} + +/* Either message selects its whole question-and-answer exchange. */ +.shiny-chat-messages-content { + position: relative; +} + +.commons-viewer-exchange-message { + cursor: pointer; + position: relative; + z-index: 1; +} + +.commons-viewer-exchange-message:hover { + background: color-mix( + in srgb, + var(--bs-primary-bg-subtle, #e7f1ff) 45%, + transparent + ); +} + +.commons-viewer-exchange-message:focus-visible { + outline: 2px solid var(--bs-primary, #0d6efd); + outline-offset: 2px; +} + +/* Softer than the raw selection token: over a whole exchange the full + * strength reads much heavier than the same color on a list entry. Selected + * (70%) sits clearly above hover (45%). */ +.commons-viewer-exchange-highlight { + background: color-mix( + in srgb, + var(--bs-primary-bg-subtle, #e7f1ff) 70%, + transparent + ); + border-radius: 0.5rem; + display: none; + left: 0; + pointer-events: none; + position: absolute; + right: 0; + z-index: 0; +} + +.commons-viewer-exchange-selected:hover { + background: transparent; +} + +@media (max-width: 900px) { + .commons-viewer-workspace { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(20rem, 1fr) auto; + } + + .commons-viewer-review-pane { + border-left: 0; + border-top: 1px solid var(--bs-border-color, #dee2e6); + max-height: 40vh; + } +} + +/* ---- Sidebar ------------------------------------------------------------ */ + +.commons-viewer-sidebar .control-label { + color: var(--bs-secondary-color, #6c757d); + font-size: 0.8125rem; + font-weight: 600; + margin-bottom: 0.25rem; +} + +/* The view-switcher tabs carry no panel content; trim the empty content + * area's contribution to the gap below. */ +.commons-viewer-sidebar .tab-content:empty { display: none; } diff --git a/inst/www/commons-viewer/commons-viewer.js b/inst/www/commons-viewer/commons-viewer.js new file mode 100644 index 0000000..8fe364d --- /dev/null +++ b/inst/www/commons-viewer/commons-viewer.js @@ -0,0 +1,155 @@ +(function() { + var register = function() { + if (!window.Shiny || !Shiny.addCustomMessageHandler) { + window.setTimeout(register, 25); + return; + } + if (window.commonsViewerExchangeInitialized) return; + window.commonsViewerExchangeInitialized = true; + + var exchangeMessages = function(chat) { + return chat.querySelectorAll( + ".shiny-chat-user-message, .shiny-chat-message" + ); + }; + + var positionHighlight = function(chat) { + var selected = chat.querySelectorAll( + ".commons-viewer-exchange-selected" + ); + var content = chat.querySelector(".shiny-chat-messages-content"); + var highlight = content && + content.querySelector(".commons-viewer-exchange-highlight"); + if (!highlight || !selected.length) { + if (highlight) highlight.style.display = "none"; + return; + } + + var first = selected[0].getBoundingClientRect(); + var last = selected[selected.length - 1].getBoundingClientRect(); + var parent = content.getBoundingClientRect(); + var padding = 8; + highlight.style.display = "block"; + highlight.style.top = first.top - parent.top - padding + "px"; + highlight.style.height = last.bottom - first.top + 2 * padding + "px"; + }; + + var ensureHighlight = function(chat) { + var content = chat.querySelector(".shiny-chat-messages-content"); + if (!content) return; + var highlight = content.querySelector( + ".commons-viewer-exchange-highlight" + ); + if (!highlight) { + highlight = document.createElement("div"); + highlight.className = "commons-viewer-exchange-highlight"; + content.prepend(highlight); + } + if (!chat.commonsViewerResizeObserver) { + chat.commonsViewerResizeObserver = new ResizeObserver(function() { + positionHighlight(chat); + }); + chat.commonsViewerResizeObserver.observe(content); + } + }; + + var selectExchange = function(chat, exchange) { + exchangeMessages(chat).forEach(function(node) { + var selected = node.dataset.exchange === String(exchange); + node.classList.toggle("commons-viewer-exchange-selected", selected); + if (node.dataset.exchange) { + node.setAttribute("aria-pressed", selected ? "true" : "false"); + } + }); + ensureHighlight(chat); + window.requestAnimationFrame(function() { + positionHighlight(chat); + }); + }; + + // Clicking the selected exchange again deselects it; `exchange: null` + // tells the server to drop its review target. + var activateExchange = function(node) { + var chat = node.closest("shiny-chat-container"); + var exchange = Number(node.dataset.exchange); + if (!chat || !Number.isInteger(exchange)) return; + var deselect = node.classList.contains( + "commons-viewer-exchange-selected" + ); + selectExchange(chat, deselect ? null : exchange); + Shiny.setInputValue( + "exchange_select", + { exchange: deselect ? null : exchange, nonce: Math.random() }, + { priority: "event" } + ); + }; + + document.addEventListener("click", function(event) { + if (!event.target || !event.target.closest) return; + var node = event.target.closest(".commons-viewer-exchange-message"); + if (!node) return; + var control = event.target.closest( + "a, button, input, textarea, select, [tabindex]" + ); + if (control && control !== node) return; + activateExchange(node); + }); + + document.addEventListener("keydown", function(event) { + if (event.key !== "Enter" && event.key !== " ") return; + var node = event.target.closest(".commons-viewer-exchange-message"); + if (!node || event.target !== node) return; + event.preventDefault(); + activateExchange(node); + }); + + // Server-driven selection state: review_target changes (including + // deselection when navigation moves away) mirror into the transcript. + Shiny.addCustomMessageHandler("commonsViewerExchangeSelect", function(message) { + var chat = document.getElementById(message.id); + if (!chat) return; + selectExchange(chat, message.exchange == null ? null : message.exchange); + }); + + Shiny.addCustomMessageHandler("commonsViewerExchangeSeed", function(message) { + var chat = document.getElementById(message.id); + if (!chat) return; + + var attempts = 0; + var stable = 0; + var lastSize = -1; + var seed = function() { + var nodes = exchangeMessages(chat); + var size = chat.textContent.length; + if (nodes.length < message.count || size !== lastSize) { + stable = 0; + } else { + stable += 1; + } + lastSize = size; + if (stable < 3) { + if (attempts++ < 200) window.setTimeout(seed, 25); + return; + } + + message.exchanges.forEach(function(exchange, i) { + var node = nodes[i]; + if (!node) return; + node.classList.add("commons-viewer-exchange-message"); + node.dataset.exchange = exchange; + node.setAttribute("role", "button"); + node.setAttribute("tabindex", "0"); + node.setAttribute( + "aria-label", + "Select question " + exchange + " for review notes" + ); + }); + selectExchange(chat, message.selected); + }; + + seed(); + }); + }; + + register(); +})(); diff --git a/man/view_trajectories.Rd b/man/view_trajectories.Rd index 230a414..051f008 100644 --- a/man/view_trajectories.Rd +++ b/man/view_trajectories.Rd @@ -4,11 +4,20 @@ \alias{view_trajectories} \title{View commons trajectories} \usage{ -view_trajectories(trajectories = read_trajectories()) +view_trajectories( + trajectories = read_trajectories(), + review_file = "commons-review.jsonl" +) } \arguments{ \item{trajectories}{A named list of conversations, as returned by \code{\link[=read_trajectories]{read_trajectories()}}.} + +\item{review_file}{Path of the JSONL file that review actions append to: +flags, unflags, and feedback notes, each with a timestamp, the +conversation id, and (for questions) the exchange number. Created on +first use; flags and notes recorded here are restored when the viewer +reopens.} } \value{ A \code{\link[shiny:shinyApp]{shiny::shinyApp()}} object. Calling \code{view_trajectories()} at the @@ -18,17 +27,27 @@ expression of an \code{app.R}. \description{ \code{view_trajectories()} launches a Shiny app for browsing conversation trajectories read with \code{\link[=read_trajectories]{read_trajectories()}}. The app shows the rate of -each trust level across conversations, a conversation list that can be -filtered by date, and a read-only transcript of each conversation with the -provenance pills the commons chat UI would show. +each trust level across conversations, a list of conversations or of +individual questions—filterable by date and trust level—and a transcript +of each with the provenance pills the commons chat UI would show. + +Transcripts are reviewable rather than live: conversations and questions +can be flagged for review, and selecting a question-and-answer exchange +allows it to be annotated with notes. Both land in \code{review_file}, one JSON +record per line, and are restored when the viewer reopens. Trajectories carry no record of how each answer was tagged when it was produced, so the viewer derives trust levels from the tool calls in the trajectory: answers backed only by governed tools (\code{call_measure}, \code{call_metrics}) are verified, and answers that used fallback tools (\code{run_sql}, \code{run_r}) count as cited when they contain citation markup and -untrusted when they don't. Citation quotes are not re-verified against the -agent's context. +untrusted when they don't. A cited answer's quotes render as footnotes so +they can be reviewed, but they are not re-verified against the agent's +context: footnotes name no source and are attributed "unverified". + +Logged calls that aren't part of the agent's question-and-answer record— +shinychat's conversation-title generation, and completions with no user +turn—are excluded from the viewer. } \examples{ \dontrun{ From 7ed5e888ff7a8304281cca4f5e01d7fbd3242ed0 Mon Sep 17 00:00:00 2001 From: skaltman Date: Fri, 31 Jul 2026 11:44:26 -0700 Subject: [PATCH 04/38] Use commons colors for conversation --- inst/www/commons-viewer/commons-viewer.css | 33 ++++++++++++---------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/inst/www/commons-viewer/commons-viewer.css b/inst/www/commons-viewer/commons-viewer.css index 2829f38..7c8a7d2 100644 --- a/inst/www/commons-viewer/commons-viewer.css +++ b/inst/www/commons-viewer/commons-viewer.css @@ -13,6 +13,15 @@ display: none; } +/* In a live commons app, commons-chat.css loads after shinychat's sheet, so + * its gray user bubble wins; here the order flips (shinychat's sheet rides + * in with the dynamically rendered chat), so restate the variables from + * commons-chat.css's shiny-chat-container block. */ +.commons-viewer-transcript shiny-chat-container { + --shiny-chat-user-message-bg: var(--bs-tertiary-bg, #f4f5f7); + --shiny-tool-card-spinner-color: var(--bs-secondary-color, #6c757d); +} + /* ---- Hit rate ----------------------------------------------------------- */ /* Border to match the verified-answer pill; bg/fg come from the box theme. */ @@ -154,27 +163,18 @@ } .commons-viewer-exchange-message:hover { - background: color-mix( - in srgb, - var(--bs-primary-bg-subtle, #e7f1ff) 45%, - transparent - ); + background: color-mix(in srgb, #007bc2 5%, transparent); } .commons-viewer-exchange-message:focus-visible { - outline: 2px solid var(--bs-primary, #0d6efd); + outline: 2px solid #007bc2; outline-offset: 2px; } -/* Softer than the raw selection token: over a whole exchange the full - * strength reads much heavier than the same color on a list entry. Selected - * (70%) sits clearly above hover (45%). */ +/* Selected (9%) sits above hover (5%): over a whole exchange even a light + * wash carries plenty of weight. */ .commons-viewer-exchange-highlight { - background: color-mix( - in srgb, - var(--bs-primary-bg-subtle, #e7f1ff) 70%, - transparent - ); + background: color-mix(in srgb, #007bc2 9%, transparent); border-radius: 0.5rem; display: none; left: 0; @@ -233,10 +233,13 @@ text-decoration: none; } +/* Selections wash with the commons blue (#007bc2, the accent + * commons-chat.css mixes from) rather than Bootstrap's primary subtle, + * which reads heavier and bluer. */ .commons-viewer-entry-selected, .commons-viewer-entry-selected:hover, .commons-viewer-entry-selected:focus { - background: var(--bs-primary-bg-subtle, #e7f1ff); + background: color-mix(in srgb, #007bc2 8%, var(--bs-body-bg, #fff)); } .commons-viewer-entry-snippet { From 522932d4eca991d78351be27ccf2b987ac6ae906 Mon Sep 17 00:00:00 2001 From: skaltman Date: Fri, 31 Jul 2026 15:33:20 -0700 Subject: [PATCH 05/38] Simplify notes label and redesign note styling --- R/view-trajectories.R | 4 +- inst/www/commons-viewer/commons-viewer.css | 62 +++++++++++++++++----- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/R/view-trajectories.R b/R/view-trajectories.R index 3299d85..b93303f 100644 --- a/R/view-trajectories.R +++ b/R/view-trajectories.R @@ -715,7 +715,7 @@ review_bar_prompt <- function(flagged) { class = "commons-viewer-review", htmltools::div( class = "commons-viewer-review-bar", - htmltools::tags$strong("Review Notes"), + htmltools::tags$strong("Notes"), flag_button(flagged, TRUE) ), htmltools::div( @@ -731,7 +731,7 @@ review_bar_notes <- function(key, flagged, notes) { htmltools::div( class = "commons-viewer-review-bar", htmltools::tags$strong( - sprintf("Review Notes for Question %d", key$exchange) + sprintf("Notes for Question %d", key$exchange) ), flag_button(flagged, FALSE) ), diff --git a/inst/www/commons-viewer/commons-viewer.css b/inst/www/commons-viewer/commons-viewer.css index 7c8a7d2..270cfe1 100644 --- a/inst/www/commons-viewer/commons-viewer.css +++ b/inst/www/commons-viewer/commons-viewer.css @@ -84,17 +84,19 @@ margin-top: 0.5rem; } +/* Notes read as annotations hung off a hairline rule -- the treatment + * citation quotes and expanded tool rows get in the transcript -- rather + * than as chat-bubble-like boxes. */ .commons-viewer-notes { display: grid; - gap: 0.375rem; - margin-top: 0.625rem; + gap: 0.625rem; + margin-top: 0.75rem; } .commons-viewer-note { - background: var(--bs-secondary-bg, #f1f3f5); - border-radius: 0.5rem; + border-left: 2px solid var(--bs-border-color, #dee2e6); font-size: 0.875rem; - padding: 0.5rem 0.625rem; + padding: 0.05rem 0 0.05rem 0.625rem; } .commons-viewer-note-text { @@ -107,11 +109,24 @@ margin-top: 0.125rem; } +/* The composer reads as one input surface, like the transcript's chat box: + * the chrome (hairline border, soft shadow, focus tint) sits on the + * container, the textarea is bare inside it, and the Add note action tucks + * under the text. */ .commons-viewer-note-compose { - align-items: flex-end; - display: flex; - gap: 0.5rem; - margin-top: 0.625rem; + background: var(--bs-body-bg, #fff); + border: 1px solid var(--bs-border-color, #dee2e6); + border-radius: 0.75rem; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); + display: grid; + margin-top: 0.75rem; + padding: 0.25rem; +} + +/* Focus moves to the container, softened toward the background the way the + * chat send button treats the primary hue. */ +.commons-viewer-note-compose:focus-within { + border-color: color-mix(in srgb, var(--bs-primary, #007bc2) 55%, var(--bs-body-bg, #fff)); } .commons-viewer-note-compose .shiny-input-container { @@ -119,12 +134,33 @@ width: 100%; } -.commons-viewer-note-compose textarea { - resize: vertical; +.commons-viewer-note-compose textarea, +.commons-viewer-note-compose textarea:focus { + background: transparent; + border: 0; + box-shadow: none; + font-size: 0.875rem; + outline: 0; + padding: 0.375rem 0.5rem; + resize: none; +} + +/* Quiet until engaged, like the transcript's tool rows: secondary text, + * darkening over a faint wash on hover instead of filling. */ +.commons-viewer-note-compose .btn, +.commons-viewer-note-compose .btn:focus { + border: 0; + color: var(--bs-secondary-color, #6c757d); + font-size: 0.8125rem; + justify-self: end; + padding: 0.25rem 0.625rem; } -.commons-viewer-note-compose .btn { - flex: 0 0 auto; +.commons-viewer-note-compose .btn:hover, +.commons-viewer-note-compose .btn:active { + background-color: rgba(var(--bs-emphasis-color-rgb, 33, 37, 41), 0.045); + border: 0; + color: var(--bs-body-color, #212529); } .commons-viewer-flag-button, From 49dcabdcaac32f485c412e154859f4fa9b8654ba Mon Sep 17 00:00:00 2001 From: skaltman Date: Fri, 31 Jul 2026 17:42:17 -0700 Subject: [PATCH 06/38] Replace value boxes with interactive trust plot --- R/view-trajectories.R | 251 ++++++++++++---- inst/www/commons-viewer/commons-viewer.css | 125 +++++++- inst/www/commons-viewer/commons-viewer.js | 325 +++++++++++++++++++++ man/view_trajectories.Rd | 9 +- tests/testthat/test-view-trajectories.R | 87 +++++- 5 files changed, 714 insertions(+), 83 deletions(-) diff --git a/R/view-trajectories.R b/R/view-trajectories.R index b93303f..6d1bdae 100644 --- a/R/view-trajectories.R +++ b/R/view-trajectories.R @@ -2,10 +2,11 @@ #' #' @description #' `view_trajectories()` launches a Shiny app for browsing conversation -#' trajectories read with [read_trajectories()]. The app shows the rate of -#' each trust level across conversations, a list of conversations or of -#' individual questions—filterable by date and trust level—and a transcript -#' of each with the provenance pills the commons chat UI would show. +#' trajectories read with [read_trajectories()]. The app charts each trust +#' level's share of answers over time—with the overall rates in the chart's +#' legend—alongside a list of conversations or of individual questions, +#' filterable by date and trust level, and a transcript of each with the +#' provenance pills the commons chat UI would show. #' #' Transcripts are reviewable rather than live: conversations and questions #' can be flagged for review, and selecting a question-and-answer exchange @@ -162,10 +163,6 @@ summarize_questions <- function(trajectories) { # dropped by the OTLP round trip -- from the tool-call names that survive # it. B vs C is decided by citation *presence*: with no agent there is no # corpus to verify quotes against. -trajectory_provenance <- function(turns) { - lapply(split_exchanges(turns), exchange_provenance) -} - exchange_provenance <- function(exchange) { tags <- exchange_tool_tags(exchange) text <- unlist(lapply(exchange, turn_text)) %||% character() @@ -424,7 +421,7 @@ viewer_ui <- function(summary) { ), shiny::uiOutput("entries") ), - shiny::uiOutput("hit_rate"), + trust_timeline_card(), bslib::card( fill = TRUE, class = "commons-viewer-transcript", @@ -500,14 +497,20 @@ viewer_server <- function(trajectories, summary, questions, review_file) { ) }) - # The hit rate reflects the date window but not the trust filter: it is - # the trust distribution the filter slices. - output$hit_rate <- shiny::renderUI({ + # The timeline reflects the date window but not the trust filter: it + # charts the trust distribution the filter slices. Its legend carries + # the window's overall rates -- including undated answers, which the + # per-day bands can't place. + output$timeline_legend <- shiny::renderUI({ in_dates <- Filter( function(i) in_window(summary[[i]], input$window), seq_along(summary) ) - hit_rate_boxes(hit_rate(lapply(in_dates, function(i) summary[[i]]$tags))) + timeline_legend(hit_rate(lapply(in_dates, function(i) summary[[i]]$tags))) + }) + + output$timeline <- shiny::renderUI({ + trust_timeline(trust_timeline_days(questions, input$window)) }) output$entries <- shiny::renderUI({ @@ -610,20 +613,25 @@ viewer_server <- function(trajectories, summary, questions, review_file) { # client-initiated selection back is harmless: re-applying it sends no # input, and a message racing a fresh transcript finds no element and # dies quietly (the exchange seed carries that state instead). - shiny::observeEvent(review_target(), ignoreNULL = FALSE, ignoreInit = TRUE, { - key <- selected() - if (is.null(key)) { - return() - } - target <- review_target() - session$sendCustomMessage( - "commonsViewerExchangeSelect", - list( - id = transcript_id(key), - exchange = if (!is.null(target)) target$exchange + shiny::observeEvent( + review_target(), + ignoreNULL = FALSE, + ignoreInit = TRUE, + { + key <- selected() + if (is.null(key)) { + return() + } + target <- review_target() + session$sendCustomMessage( + "commonsViewerExchangeSelect", + list( + id = transcript_id(key), + exchange = if (!is.null(target)) target$exchange + ) ) - ) - }) + } + ) shiny::observeEvent(input$save_note, { key <- review_target() @@ -798,14 +806,6 @@ append_review_record <- function(file, record) { cat(line, "\n", file = file, sep = "", append = TRUE) } -read_review_flags <- function(file) { - review_flags(read_review_records(file)) -} - -read_review_notes <- function(file) { - review_notes(read_review_records(file)) -} - read_review_records <- function(file) { if (!file.exists(file)) { return(list()) @@ -905,35 +905,6 @@ viewer_levels <- c( none = "No data tool" ) -hit_rate_boxes <- function(rate) { - boxes <- lapply(names(viewer_levels), function(key) level_box(key, rate)) - do.call(bslib::layout_columns, c(boxes, list(fill = FALSE))) -} - -# The Verified box wears the verified-answer pill's palette (see -# .commons-answer-pill-trusted in commons-chat.css) and its shield. -level_box <- function(key, rate) { - verified <- identical(key, "A") - bslib::value_box( - title = viewer_levels[[key]], - value = rate_percent(rate$counts[[key]], rate$n), - htmltools::p(sprintf("%d of %d answers", rate$counts[[key]], rate$n)), - showcase = if (verified) verified_shield(), - theme = if (verified) { - bslib::value_box_theme(bg = "#f2fbf5", fg = "#286144") - }, - class = if (verified) "commons-viewer-value-verified" - ) -} - -# The same shield the verified-answer pill carries, inlined so the value -# box's showcase can size it. -verified_shield <- function() { - path <- system.file("figs", "trusted-icon.svg", package = "commons") - svg <- paste(readLines(path, warn = FALSE), collapse = "\n") - htmltools::HTML(sub("^\\s*<\\?xml[^>]*\\?>\\s*", "", svg)) -} - rate_percent <- function(count, n) { if (n == 0) { return("\u2014") @@ -1046,6 +1017,160 @@ local_date <- function(time) { as.Date(format(time, "%Y-%m-%d")) } +# Timeline ------------------------------------------------------------------ + +# One fill per trust level, in stack order (Verified at the baseline). The +# set passes the usual palette gates -- colorblind separation between stack +# neighbors and 3:1 contrast on a white surface -- which is why "No data +# tool" wears a muted violet rather than a gray that would read as +# background, and Untrusted a darker amber than its pill. +viewer_level_colors <- c( + A = "#2a9d64", + B = "#2a78d6", + C = "#b8860b", + none = "#8a72c8" +) + +# The card frame and title are static; the legend and plot re-render as +# the date window moves. +trust_timeline_card <- function() { + bslib::card( + fill = FALSE, + class = "commons-viewer-timeline-card", + bslib::card_header( + class = "commons-viewer-timeline-header", + htmltools::tags$strong("Trust levels over time"), + shiny::uiOutput("timeline_legend", inline = TRUE) + ), + shiny::uiOutput("timeline") + ) +} + +# The legend doubles as the viewer's headline hit rate: each level's +# window-wide share of answers rides its legend entry, with the counts +# behind the percentage in the entry's tooltip. +timeline_legend <- function(rate) { + htmltools::div( + class = "commons-viewer-timeline-legend", + lapply(names(viewer_levels), function(key) { + htmltools::tags$span( + class = "commons-viewer-timeline-legend-item", + title = sprintf( + "%d of %d answers", + rate$counts[[key]], + rate$n + ), + htmltools::tags$span( + class = "commons-viewer-timeline-swatch", + style = paste0("background:", viewer_level_colors[[key]]) + ), + viewer_levels[[key]], + htmltools::tags$strong(rate_percent(rate$counts[[key]], rate$n)) + ) + }) + ) +} + +# Per-day tag counts for the dated questions inside the window, in date +# order. Undated questions have no x position, so the chart skips them; +# the hit-rate boxes still count them. +trust_timeline_days <- function(questions, window = NULL) { + dates <- as.Date(vapply( + questions, + function(record) as.character(local_date(record$last_active)), + character(1) + )) + keep <- !is.na(dates) + if (length(window) >= 2) { + keep <- keep & dates >= window[[1]] & dates <= window[[2]] + } + questions <- questions[keep] + dates <- dates[keep] + + lapply(sort(unique(dates)), function(day) { + tags <- vapply( + questions[dates == day], + function(record) record$tag, + character(1) + ) + list( + date = format(day, "%Y-%m-%d"), + label = format(day, "%b %e, %Y"), + n = length(tags), + counts = list( + A = sum(tags %in% "A"), + B = sum(tags %in% "B"), + C = sum(tags %in% "C"), + none = sum(is.na(tags)) + ) + ) + }) +} + +# The plot itself is drawn client-side (commons-viewer.js) from the JSON +# payload, so it can size to the card and carry the crosshair tooltip; the +# table is the same data readable without a pointer, and is where screen +# readers land instead of the drawing. +trust_timeline <- function(days) { + if (length(days) == 0) { + return(viewer_empty_note("No dated questions in this date range.")) + } + payload <- list( + levels = lapply(names(viewer_levels), function(key) { + list( + key = key, + label = unname(viewer_levels[[key]]), + color = unname(viewer_level_colors[[key]]) + ) + }), + days = days + ) + htmltools::div( + class = "commons-viewer-timeline", + htmltools::tags$script( + type = "application/json", + class = "commons-viewer-timeline-data", + htmltools::HTML(jsonlite::toJSON(payload, auto_unbox = TRUE)) + ), + htmltools::div( + class = "commons-viewer-timeline-plot", + role = "img", + tabindex = "0", + `aria-label` = paste( + "Chart of the share of answers at each trust level by day.", + "The values appear in the table that follows." + ) + ), + timeline_table(days) + ) +} + +timeline_table <- function(days) { + rows <- lapply(days, function(day) { + htmltools::tags$tr( + htmltools::tags$td(day$label), + lapply(names(viewer_levels), function(key) { + htmltools::tags$td(sprintf( + "%s (%d)", + rate_percent(day$counts[[key]], day$n), + day$counts[[key]] + )) + }), + htmltools::tags$td(day$n) + ) + }) + htmltools::tags$table( + class = "commons-viewer-sr-only", + htmltools::tags$caption("Trust levels over time"), + htmltools::tags$thead(htmltools::tags$tr( + htmltools::tags$th("Date"), + lapply(unname(viewer_levels), htmltools::tags$th), + htmltools::tags$th("Answers") + )), + htmltools::tags$tbody(rows) + ) +} + # 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/inst/www/commons-viewer/commons-viewer.css b/inst/www/commons-viewer/commons-viewer.css index 270cfe1..0b37cec 100644 --- a/inst/www/commons-viewer/commons-viewer.css +++ b/inst/www/commons-viewer/commons-viewer.css @@ -22,16 +22,127 @@ --shiny-tool-card-spinner-color: var(--bs-secondary-color, #6c757d); } -/* ---- Hit rate ----------------------------------------------------------- */ +/* ---- Timeline ----------------------------------------------------------- */ -/* Border to match the verified-answer pill; bg/fg come from the box theme. */ -.commons-viewer-value-verified { - border: 1px solid #cfeedd; +.commons-viewer-timeline-card { + flex: 0 0 auto; } -.commons-viewer-value-verified .value-box-showcase svg { - height: 2.5rem; - width: 2.5rem; +.commons-viewer-timeline-card .card-body { + padding: 0.5rem 0.75rem 0.625rem; +} + +.commons-viewer-timeline-header { + align-items: center; + background: transparent; + border-bottom: 0; + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1rem; + justify-content: space-between; + padding: 0.75rem 0.75rem 0; +} + +/* The title matches the sidebar's Dates / Trust Level captions. */ +.commons-viewer-timeline-header strong { + color: var(--bs-secondary-color, #6c757d); + font-size: 0.8125rem; + font-weight: 600; +} + +.commons-viewer-timeline-legend { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 0.875rem; +} + +.commons-viewer-timeline-legend-item { + align-items: center; + color: var(--bs-secondary-color, #6c757d); + display: inline-flex; + font-size: 0.75rem; + gap: 0.35rem; +} + +/* The window-wide rate reads as the entry's value: body ink, tabular so + * the row doesn't shimmy as the date window moves. */ +.commons-viewer-timeline-legend-item strong { + color: var(--bs-body-color, #212529); + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.commons-viewer-timeline-swatch { + border-radius: 2px; + display: inline-block; + flex: 0 0 auto; + height: 0.625rem; + width: 0.625rem; +} + +.commons-viewer-timeline { + position: relative; +} + +.commons-viewer-timeline-plot { + height: 11rem; + width: 100%; +} + +.commons-viewer-timeline-plot:focus-visible { + border-radius: 0.25rem; + outline: 2px solid #007bc2; + outline-offset: 2px; +} + +.commons-viewer-timeline-plot svg { + display: block; +} + +.commons-viewer-timeline-tooltip { + background: var(--bs-body-bg, #fff); + border: 1px solid var(--bs-border-color, #dee2e6); + border-radius: 0.5rem; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12); + display: none; + font-size: 0.75rem; + padding: 0.5rem 0.625rem; + pointer-events: none; + position: absolute; + z-index: 5; +} + +.commons-viewer-timeline-tooltip-date { + color: var(--bs-secondary-color, #6c757d); + margin-bottom: 0.25rem; + white-space: nowrap; +} + +/* Values lead: the number is the strong element, the level name follows. */ +.commons-viewer-timeline-tooltip-row { + align-items: center; + display: flex; + gap: 0.4rem; + white-space: nowrap; +} + +.commons-viewer-timeline-tooltip-row strong { + font-variant-numeric: tabular-nums; + min-width: 2.25rem; + text-align: right; +} + +/* Visually hidden, still read: the chart's table view. */ +.commons-viewer-sr-only { + border: 0; + clip: rect(0, 0, 0, 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; } /* ---- Review ------------------------------------------------------------ */ diff --git a/inst/www/commons-viewer/commons-viewer.js b/inst/www/commons-viewer/commons-viewer.js index 8fe364d..6cc58c9 100644 --- a/inst/www/commons-viewer/commons-viewer.js +++ b/inst/www/commons-viewer/commons-viewer.js @@ -153,3 +153,328 @@ register(); })(); + +// Trust-level timeline: a 100%-stacked area chart drawn client-side from +// the JSON payload trust_timeline() renders, so it can size to its card +// and re-draw as the card resizes. All values it shows on hover also live +// in the adjacent (visually hidden) table. +(function() { + var SVG = "http://www.w3.org/2000/svg"; + // Chart chrome shares the app's text/border tokens; the surface color + // doubles as the 2px gap separating stacked bands. + var SURFACE = "var(--bs-card-bg, #fff)"; + var GRID = "var(--bs-border-color, #dee2e6)"; + var TEXT = "var(--bs-secondary-color, #6c757d)"; + var MARGIN = { top: 8, right: 12, bottom: 22, left: 40 }; + + var element = function(name, attributes, parent) { + var node = document.createElementNS(SVG, name); + Object.keys(attributes || {}).forEach(function(key) { + node.setAttribute(key, attributes[key]); + }); + if (parent) parent.appendChild(node); + return node; + }; + + // Cumulative share boundaries per day: boundaries[i][k] is the fraction + // of day i's answers at or below stack level k. + var boundaries = function(days, levels) { + return days.map(function(day) { + var total = 0; + return levels.map(function(level) { + total += day.counts[level.key] / day.n; + return Math.min(total, 1); + }); + }); + }; + + var timeScale = function(days, left, width) { + var times = days.map(function(day) { + return Date.parse(day.date + "T00:00:00Z"); + }); + var min = times[0]; + var span = times[times.length - 1] - min || 1; + return times.map(function(time) { + return left + ((time - min) / span) * width; + }); + }; + + // Date ticks at roughly 90px spacing, always including the first and + // last day; indices are deduplicated when the chart is narrow. + var tickIndexes = function(count, width) { + var target = Math.max(2, Math.min(count, Math.floor(width / 90) + 1)); + var indexes = []; + for (var i = 0; i < target; i++) { + var index = Math.round((i * (count - 1)) / (target - 1)); + if (indexes.indexOf(index) === -1) indexes.push(index); + } + return indexes; + }; + + var shortLabel = function(day) { + return day.label.replace(/,\s*\d{4}$/, "").replace(/\s+/g, " "); + }; + + var drawFrame = function(svg, geometry) { + [0, 0.5, 1].forEach(function(share) { + var y = geometry.y(share); + element("line", { + x1: MARGIN.left, x2: geometry.right, y1: y, y2: y, + stroke: GRID, "stroke-width": 1 + }, svg); + var label = element("text", { + x: MARGIN.left - 8, y: y + 3.5, + "text-anchor": "end", "font-size": 11, fill: TEXT + }, svg); + label.textContent = Math.round(share * 100) + "%"; + }); + }; + + var drawTicks = function(svg, geometry, days, xs) { + tickIndexes(days.length, geometry.right - MARGIN.left) + .forEach(function(index) { + var anchor = index === 0 ? "start" : + index === days.length - 1 ? "end" : "middle"; + var label = element("text", { + x: xs[index], y: geometry.bottom + 15, + "text-anchor": anchor, "font-size": 11, fill: TEXT + }, svg); + label.textContent = shortLabel(days[index]); + }); + }; + + var drawBands = function(svg, geometry, payload, xs, stacked) { + payload.levels.forEach(function(level, k) { + var upper = xs.map(function(x, i) { + return x + "," + geometry.y(stacked[i][k]); + }); + var lower = xs.map(function(x, i) { + return x + "," + geometry.y(k === 0 ? 0 : stacked[i][k - 1]); + }); + element("polygon", { + points: upper.concat(lower.reverse()).join(" "), + fill: level.color + }, svg); + }); + // Interior boundaries redrawn as surface-colored lines: the 2px gap + // that keeps neighboring bands apart without adding stroke ink. + for (var k = 0; k + 1 < payload.levels.length; k++) { + element("polyline", { + points: xs.map(function(x, i) { + return x + "," + geometry.y(stacked[i][k]); + }).join(" "), + fill: "none", stroke: SURFACE, "stroke-width": 2 + }, svg); + } + }; + + // A single dated day can't make an area; it gets one stacked column with + // the same gaps, rounded at the top of the stack, square at the baseline. + var drawColumn = function(svg, geometry, payload, x, stacked) { + var half = 12; + payload.levels.forEach(function(level, k) { + var top = geometry.y(stacked[0][k]); + var bottom = geometry.y(k === 0 ? 0 : stacked[0][k - 1]); + if (bottom - top < 0.5) return; + var gap = k === 0 ? 0 : 2; + var rounded = stacked[0][k] >= 1 - 1e-9; + element("path", { + d: rounded + ? "M" + (x - half) + " " + (bottom - gap) + + "V" + (top + 4) + + "Q" + (x - half) + " " + top + " " + (x - half + 4) + " " + top + + "H" + (x + half - 4) + + "Q" + (x + half) + " " + top + " " + (x + half) + " " + (top + 4) + + "V" + (bottom - gap) + "Z" + : "M" + (x - half) + " " + (bottom - gap) + + "V" + top + "H" + (x + half) + "V" + (bottom - gap) + "Z", + fill: level.color + }, svg); + }); + }; + + var buildTooltip = function(state, index) { + var day = state.payload.days[index]; + var tooltip = state.tooltip; + tooltip.textContent = ""; + var heading = document.createElement("div"); + heading.className = "commons-viewer-timeline-tooltip-date"; + heading.textContent = day.label.replace(/\s+/g, " ") + + " · " + day.n + (day.n === 1 ? " answer" : " answers"); + tooltip.appendChild(heading); + state.payload.levels.forEach(function(level) { + var row = document.createElement("div"); + row.className = "commons-viewer-timeline-tooltip-row"; + var swatch = document.createElement("span"); + swatch.className = "commons-viewer-timeline-swatch"; + swatch.style.background = level.color; + var value = document.createElement("strong"); + value.textContent = + Math.round((100 * day.counts[level.key]) / day.n) + "%"; + var label = document.createElement("span"); + label.textContent = level.label; + row.appendChild(swatch); + row.appendChild(value); + row.appendChild(label); + tooltip.appendChild(row); + }); + }; + + var showIndex = function(state, index) { + if (!state.geometry) return; + state.index = index; + var x = state.xs[index]; + state.crosshair.setAttribute("x1", x); + state.crosshair.setAttribute("x2", x); + state.crosshair.style.display = "block"; + buildTooltip(state, index); + var tooltip = state.tooltip; + tooltip.style.display = "block"; + var plotWidth = state.plot.clientWidth; + var width = tooltip.offsetWidth; + var left = x + 12 + width > plotWidth ? x - 12 - width : x + 12; + tooltip.style.left = Math.max(0, left) + "px"; + tooltip.style.top = MARGIN.top + "px"; + }; + + var hideIndex = function(state) { + state.index = null; + state.crosshair.style.display = "none"; + state.tooltip.style.display = "none"; + }; + + var nearestIndex = function(state, clientX) { + var offset = clientX - state.plot.getBoundingClientRect().left; + var best = 0; + state.xs.forEach(function(x, i) { + if (Math.abs(x - offset) < Math.abs(state.xs[best] - offset)) best = i; + }); + return best; + }; + + var drawTimeline = function(state) { + var plot = state.plot; + var payload = state.payload; + var width = plot.clientWidth; + var height = plot.clientHeight; + if (width <= MARGIN.left + MARGIN.right || height <= 0) return; + + var geometry = { + right: width - MARGIN.right, + bottom: height - MARGIN.bottom, + y: function(share) { + return MARGIN.top + + (1 - share) * (height - MARGIN.top - MARGIN.bottom); + } + }; + var stacked = boundaries(payload.days, payload.levels); + var xs = payload.days.length === 1 + ? [(MARGIN.left + geometry.right) / 2] + : timeScale(payload.days, MARGIN.left, geometry.right - MARGIN.left); + + plot.textContent = ""; + var svg = element("svg", { width: width, height: height }); + drawFrame(svg, geometry); + if (payload.days.length === 1) { + drawColumn(svg, geometry, payload, xs[0], stacked); + } else { + drawBands(svg, geometry, payload, xs, stacked); + } + drawTicks(svg, geometry, payload.days, xs); + state.crosshair = element("line", { + y1: MARGIN.top, y2: geometry.bottom, + stroke: TEXT, "stroke-width": 1, style: "display: none" + }, svg); + plot.appendChild(svg); + + state.geometry = geometry; + state.xs = xs; + if (state.index != null && state.index < payload.days.length) { + showIndex(state, state.index); + } + }; + + var attachPointer = function(state) { + state.plot.addEventListener("pointermove", function(event) { + showIndex(state, nearestIndex(state, event.clientX)); + }); + state.plot.addEventListener("pointerleave", function() { + hideIndex(state); + }); + state.plot.addEventListener("keydown", function(event) { + var last = state.payload.days.length - 1; + var moves = { + ArrowLeft: state.index == null ? last : Math.max(0, state.index - 1), + ArrowRight: state.index == null + ? 0 + : Math.min(last, state.index + 1), + Home: 0, + End: last + }; + if (event.key === "Escape") { + hideIndex(state); + } else if (event.key in moves) { + showIndex(state, moves[event.key]); + } else { + return; + } + event.preventDefault(); + }); + state.plot.addEventListener("blur", function() { + hideIndex(state); + }); + }; + + var initTimeline = function(container) { + if (container.commonsViewerTimeline) return; + container.commonsViewerTimeline = true; + var script = container.querySelector(".commons-viewer-timeline-data"); + var plot = container.querySelector(".commons-viewer-timeline-plot"); + if (!script || !plot) return; + var payload; + try { + payload = JSON.parse(script.textContent); + } catch (error) { + return; + } + if (!payload.days || !payload.days.length) return; + + var tooltip = document.createElement("div"); + tooltip.className = "commons-viewer-timeline-tooltip"; + container.appendChild(tooltip); + + var state = { + payload: payload, + plot: plot, + tooltip: tooltip, + index: null + }; + drawTimeline(state); + attachPointer(state); + new ResizeObserver(function() { + drawTimeline(state); + }).observe(plot); + }; + + var scan = function() { + document + .querySelectorAll(".commons-viewer-timeline") + .forEach(initTimeline); + }; + + // The timeline arrives with each renderUI flush; watch for it rather + // than hooking Shiny's (jQuery-only) render events. + var observe = function() { + if (!document.body) { + window.setTimeout(observe, 25); + return; + } + new MutationObserver(scan).observe(document.body, { + childList: true, + subtree: true + }); + scan(); + }; + + observe(); +})(); diff --git a/man/view_trajectories.Rd b/man/view_trajectories.Rd index 051f008..43fd126 100644 --- a/man/view_trajectories.Rd +++ b/man/view_trajectories.Rd @@ -26,10 +26,11 @@ expression of an \code{app.R}. } \description{ \code{view_trajectories()} launches a Shiny app for browsing conversation -trajectories read with \code{\link[=read_trajectories]{read_trajectories()}}. The app shows the rate of -each trust level across conversations, a list of conversations or of -individual questions—filterable by date and trust level—and a transcript -of each with the provenance pills the commons chat UI would show. +trajectories read with \code{\link[=read_trajectories]{read_trajectories()}}. The app charts each trust +level's share of answers over time—with the overall rates in the chart's +legend—alongside a list of conversations or of individual questions, +filterable by date and trust level, and a transcript of each with the +provenance pills the commons chat UI would show. Transcripts are reviewable rather than live: conversations and questions can be flagged for review, and selecting a question-and-answer exchange diff --git a/tests/testthat/test-view-trajectories.R b/tests/testthat/test-view-trajectories.R index de2079c..02870a7 100644 --- a/tests/testthat/test-view-trajectories.R +++ b/tests/testthat/test-view-trajectories.R @@ -15,20 +15,20 @@ test_tool_turns <- function(name, id = "c1") { ) } -test_that("trajectory_provenance derives tags from tool calls and citations", { +test_that("exchange_provenance derives tags from tool calls and citations", { measure <- c( list(ellmer::UserTurn("How many orders?")), test_tool_turns("call_measure"), list(ellmer::AssistantTurn("6 orders.")) ) - expect_equal(trajectory_provenance(measure)[[1]]$tag, "A") + expect_equal(exchange_provenance(measure)$tag, "A") uncited <- c( list(ellmer::UserTurn("Total revenue?")), test_tool_turns("run_sql"), list(ellmer::AssistantTurn("5650.")) ) - expect_equal(trajectory_provenance(uncited)[[1]]$tag, "C") + expect_equal(exchange_provenance(uncited)$tag, "C") # Citation *presence* makes a fallback answer "B": with no agent there is # no corpus, so even an unverifiable quote counts. @@ -39,7 +39,7 @@ test_that("trajectory_provenance derives tags from tool calls and citations", { "5650.\n\nRevenue excludes tax." )) ) - expect_equal(trajectory_provenance(cited)[[1]]$tag, "B") + expect_equal(exchange_provenance(cited)$tag, "B") mixed <- c( list(ellmer::UserTurn("Total revenue?")), @@ -49,14 +49,14 @@ test_that("trajectory_provenance derives tags from tool calls and citations", { "5650. Revenue excludes tax." )) ) - expect_equal(trajectory_provenance(mixed)[[1]]$tag, "B") + expect_equal(exchange_provenance(mixed)$tag, "B") untagged <- c( list(ellmer::UserTurn("What does revenue mean?")), test_tool_turns("search_context"), list(ellmer::AssistantTurn("Revenue excludes tax.")) ) - expect_true(is.na(trajectory_provenance(untagged)[[1]]$tag)) + expect_true(is.na(exchange_provenance(untagged)$tag)) }) test_that("tool names survive the OTLP round trip and drive derivation", { @@ -76,7 +76,7 @@ test_that("tool names survive the OTLP round trip and drive derivation", { ))) turns <- build_trajectories(spans)[[1]] - provenance <- trajectory_provenance(turns) + provenance <- lapply(split_exchanges(turns), exchange_provenance) expect_length(provenance, 1) expect_equal(provenance[[1]]$tag, "B") @@ -396,9 +396,78 @@ test_that("flags and notes append to and restore from the review file", { expect_equal(records[[2]]$exchange, 1) expect_equal(records[[3]]$note, "Wrong join, should use orders.") - expect_equal(read_review_flags(review_file), c("conv1", "conv1#1")) + restored <- read_review_records(review_file) + expect_equal(review_flags(restored), c("conv1", "conv1#1")) expect_equal( - vapply(read_review_notes(review_file), `[[`, character(1), "note"), + vapply(review_notes(restored), `[[`, character(1), "note"), "Wrong join, should use orders." ) }) + +test_that("trust_timeline_days aggregates question tags by day", { + questions <- list( + list(tag = "A", last_active = as.POSIXct("2026-07-01 09:00:00")), + list(tag = "C", last_active = as.POSIXct("2026-07-01 15:00:00")), + list(tag = NA_character_, last_active = as.POSIXct("2026-07-03 10:00:00")), + list(tag = "B", last_active = as.POSIXct(NA)) + ) + + days <- trust_timeline_days(questions) + + # The undated question has no x position, so only two days chart. + expect_length(days, 2) + expect_equal(days[[1]]$date, "2026-07-01") + expect_equal(days[[1]]$n, 2) + expect_equal(days[[1]]$counts, list(A = 1L, B = 0L, C = 1L, none = 0L)) + expect_equal(days[[2]]$counts, list(A = 0L, B = 0L, C = 0L, none = 1L)) + + windowed <- trust_timeline_days( + questions, + as.Date(c("2026-07-02", "2026-07-04")) + ) + expect_length(windowed, 1) + expect_equal(windowed[[1]]$date, "2026-07-03") +}) + +test_that("trust_timeline renders a chart payload and its table view", { + skip_if_not_installed("htmltools") + + empty <- as.character(trust_timeline(list())) + expect_match(empty, "No dated questions") + + days <- trust_timeline_days(list( + list(tag = "A", last_active = as.POSIXct("2026-07-01 09:00:00")), + list(tag = "A", last_active = as.POSIXct("2026-07-02 09:00:00")), + list(tag = "C", last_active = as.POSIXct("2026-07-02 16:00:00")) + )) + html <- as.character(trust_timeline(days)) + + json <- sub(".*]*>", "", html) + json <- sub(".*", "", json) + payload <- jsonlite::fromJSON(json, simplifyVector = FALSE) + expect_equal( + vapply(payload$levels, function(level) level$key, character(1)), + c("A", "B", "C", "none") + ) + expect_equal(payload$days[[2]]$n, 2) + expect_equal(payload$days[[2]]$counts$C, 1) + + # The table view carries every share the tooltip shows. + expect_match(html, "commons-viewer-sr-only") + expect_match(html, "50% (1)", fixed = TRUE) +}) + +test_that("the timeline legend carries each level's window-wide rate", { + skip_if_not_installed("htmltools") + + legend <- as.character(timeline_legend( + hit_rate(list(c("A", "C"), "B", NA_character_)) + )) + + expect_match(legend, "Verified") + expect_match(legend, "25%", fixed = TRUE) + expect_match(legend, "1 of 4 answers") + + empty <- as.character(timeline_legend(hit_rate(list()))) + expect_match(empty, "—") +}) From 2c0306b0f1d9da8c60a93a49a9fb118e82de3367 Mon Sep 17 00:00:00 2001 From: skaltman Date: Fri, 31 Jul 2026 17:42:27 -0700 Subject: [PATCH 07/38] Add review files to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 80e4911..900a359 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ CLAUDE.local.md AGENTS.override.md inst/hex/output .shinychat/ +commons-review.jsonl From d9ae2f72bcf46ef26a2479a34ec6e1e3e5c0acdb Mon Sep 17 00:00:00 2001 From: skaltman Date: Mon, 3 Aug 2026 11:01:34 -0700 Subject: [PATCH 08/38] Replace client-side SVG timeline chart with plotly widget --- R/view-trajectories.R | 139 +++++++-- inst/www/commons-viewer/commons-viewer.css | 50 +--- inst/www/commons-viewer/commons-viewer.js | 325 --------------------- tests/testthat/test-view-trajectories.R | 24 +- 4 files changed, 133 insertions(+), 405 deletions(-) diff --git a/R/view-trajectories.R b/R/view-trajectories.R index 6d1bdae..f6a9523 100644 --- a/R/view-trajectories.R +++ b/R/view-trajectories.R @@ -62,7 +62,7 @@ view_trajectories <- function( } check_viewer_packages <- function(call = rlang::caller_env()) { - pkgs <- c("bslib", "htmltools", "shiny", "shinychat", "shinyWidgets") + pkgs <- c("bslib", "htmltools", "plotly", "shiny", "shinychat", "shinyWidgets") missing <- pkgs[!vapply(pkgs, requireNamespace, logical(1), quietly = TRUE)] if (length(missing)) { @@ -412,7 +412,10 @@ viewer_ui <- function(summary) { maxDate = dates$max, dateFormat = "MMM d, yyyy", update_on = "close", - addon = "none" + addon = "none", + # With toggling on, clicking the range's start date a second time + # deselects it, making a one-day window unreachable. + toggleSelected = FALSE ), shiny::selectInput( "trust", @@ -1073,7 +1076,7 @@ timeline_legend <- function(rate) { # Per-day tag counts for the dated questions inside the window, in date # order. Undated questions have no x position, so the chart skips them; -# the hit-rate boxes still count them. +# the legend still counts them. trust_timeline_days <- function(questions, window = NULL) { dates <- as.Date(vapply( questions, @@ -1107,44 +1110,132 @@ trust_timeline_days <- function(questions, window = NULL) { }) } -# The plot itself is drawn client-side (commons-viewer.js) from the JSON -# payload, so it can size to the card and carry the crosshair tooltip; the -# table is the same data readable without a pointer, and is where screen -# readers land instead of the drawing. +# The table is the same data as the chart, readable without a pointer, and +# is where screen readers land instead of the drawing: role="img" on the +# plot's wrapper keeps plotly's internals out of the accessibility tree. trust_timeline <- function(days) { if (length(days) == 0) { return(viewer_empty_note("No dated questions in this date range.")) } - payload <- list( - levels = lapply(names(viewer_levels), function(key) { - list( - key = key, - label = unname(viewer_levels[[key]]), - color = unname(viewer_level_colors[[key]]) - ) - }), - days = days - ) htmltools::div( class = "commons-viewer-timeline", - htmltools::tags$script( - type = "application/json", - class = "commons-viewer-timeline-data", - htmltools::HTML(jsonlite::toJSON(payload, auto_unbox = TRUE)) - ), htmltools::div( class = "commons-viewer-timeline-plot", role = "img", - tabindex = "0", `aria-label` = paste( "Chart of the share of answers at each trust level by day.", "The values appear in the table that follows." - ) + ), + timeline_plot(days) ), timeline_table(days) ) } +# A 100%-stacked area chart of each day's trust-level shares -- one stacked +# column when only one day is dated, since a single point can't make an +# area. Unified hover fires anywhere in the fills and mirrors the app's +# tooltip styling: a date header, then a swatch row per level with the +# share in bold. The card header's legend names the levels, so the +# widget's own legend stays off. +timeline_plot <- function(days) { + dates <- as.Date(vapply(days, function(day) day$date, character(1))) + n <- vapply(days, function(day) day$n, numeric(1)) + plot <- plotly::plot_ly(height = 176) + + for (k in seq_along(viewer_levels)) { + key <- names(viewer_levels)[[k]] + counts <- vapply(days, function(day) day$counts[[key]], numeric(1)) + shares <- 100 * counts / n + hovertemplate <- paste0( + "%{y:.0f}% ", viewer_levels[[key]], "" + ) + plot <- if (length(days) == 1) { + plotly::add_bars( + plot, + x = dates, + y = shares, + name = unname(viewer_levels[[key]]), + hovertemplate = hovertemplate, + marker = list(color = viewer_level_colors[[key]]), + # About two hours wide, in the date axis's milliseconds; with the + # axis pinned a day either side, a column rather than a fill. + width = 7200000 + ) + } else { + plotly::add_trace( + plot, + x = dates, + y = shares, + name = unname(viewer_levels[[key]]), + hovertemplate = hovertemplate, + type = "scatter", + mode = "lines", + stackgroup = "levels", + fillcolor = viewer_level_colors[[key]], + # The surface-colored boundary line is the 2px gap keeping + # neighboring bands apart; the top band's boundary is the chart's + # edge and draws nothing. + line = list( + color = "#ffffff", + width = if (k == length(viewer_levels)) 0 else 2 + ) + ) + } + } + + # Ticks sit on dated days themselves rather than plotly's auto ticks, + # which land on empty dates between them and wrap into two lines ("Jul 2" + # over "2026") that the bottom margin can't fit: up to seven days, always + # including the first and last, as single-line month-day labels. + ticks <- unique(round(seq( + 1, + length(dates), + length.out = min(length(dates), 7) + ))) + + plot <- plotly::layout( + plot, + barmode = "stack", + hovermode = "x unified", + hoverlabel = list( + bgcolor = "#ffffff", + bordercolor = "#dee2e6", + font = list(size = 12, color = "#212529") + ), + showlegend = FALSE, + margin = list(t = 8, r = 12, b = 22, l = 40), + paper_bgcolor = "transparent", + plot_bgcolor = "transparent", + font = list(size = 11, color = "#6c757d"), + xaxis = list( + title = FALSE, + type = "date", + showgrid = FALSE, + fixedrange = TRUE, + # Unified hover draws a spike line down to the axis by default; the + # tooltip alone is enough. + showspikes = FALSE, + hoverformat = "%b %e, %Y", + tickvals = as.list(format(dates[ticks])), + ticktext = as.list(format(dates[ticks], "%b %e")), + # A lone day gives autorange nothing but the column's own edges to + # work with, so it would stretch the column across the card. + range = if (length(days) == 1) as.list(format(dates + c(-1, 1))) + ), + yaxis = list( + title = FALSE, + range = c(0, 100), + tickvals = c(0, 50, 100), + ticksuffix = "%", + gridcolor = "#dee2e6", + zeroline = FALSE, + fixedrange = TRUE + ) + ) + plotly::config(plot, displayModeBar = FALSE, responsive = TRUE) +} + timeline_table <- function(days) { rows <- lapply(days, function(day) { htmltools::tags$tr( diff --git a/inst/www/commons-viewer/commons-viewer.css b/inst/www/commons-viewer/commons-viewer.css index 0b37cec..c981445 100644 --- a/inst/www/commons-viewer/commons-viewer.css +++ b/inst/www/commons-viewer/commons-viewer.css @@ -80,58 +80,14 @@ width: 0.625rem; } -.commons-viewer-timeline { - position: relative; -} - +/* The plotly widget carries a fixed 176px height (timeline_plot()); the + * wrapper's matching height keeps the card from collapsing before the + * widget renders. */ .commons-viewer-timeline-plot { height: 11rem; width: 100%; } -.commons-viewer-timeline-plot:focus-visible { - border-radius: 0.25rem; - outline: 2px solid #007bc2; - outline-offset: 2px; -} - -.commons-viewer-timeline-plot svg { - display: block; -} - -.commons-viewer-timeline-tooltip { - background: var(--bs-body-bg, #fff); - border: 1px solid var(--bs-border-color, #dee2e6); - border-radius: 0.5rem; - box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12); - display: none; - font-size: 0.75rem; - padding: 0.5rem 0.625rem; - pointer-events: none; - position: absolute; - z-index: 5; -} - -.commons-viewer-timeline-tooltip-date { - color: var(--bs-secondary-color, #6c757d); - margin-bottom: 0.25rem; - white-space: nowrap; -} - -/* Values lead: the number is the strong element, the level name follows. */ -.commons-viewer-timeline-tooltip-row { - align-items: center; - display: flex; - gap: 0.4rem; - white-space: nowrap; -} - -.commons-viewer-timeline-tooltip-row strong { - font-variant-numeric: tabular-nums; - min-width: 2.25rem; - text-align: right; -} - /* Visually hidden, still read: the chart's table view. */ .commons-viewer-sr-only { border: 0; diff --git a/inst/www/commons-viewer/commons-viewer.js b/inst/www/commons-viewer/commons-viewer.js index 6cc58c9..8fe364d 100644 --- a/inst/www/commons-viewer/commons-viewer.js +++ b/inst/www/commons-viewer/commons-viewer.js @@ -153,328 +153,3 @@ register(); })(); - -// Trust-level timeline: a 100%-stacked area chart drawn client-side from -// the JSON payload trust_timeline() renders, so it can size to its card -// and re-draw as the card resizes. All values it shows on hover also live -// in the adjacent (visually hidden) table. -(function() { - var SVG = "http://www.w3.org/2000/svg"; - // Chart chrome shares the app's text/border tokens; the surface color - // doubles as the 2px gap separating stacked bands. - var SURFACE = "var(--bs-card-bg, #fff)"; - var GRID = "var(--bs-border-color, #dee2e6)"; - var TEXT = "var(--bs-secondary-color, #6c757d)"; - var MARGIN = { top: 8, right: 12, bottom: 22, left: 40 }; - - var element = function(name, attributes, parent) { - var node = document.createElementNS(SVG, name); - Object.keys(attributes || {}).forEach(function(key) { - node.setAttribute(key, attributes[key]); - }); - if (parent) parent.appendChild(node); - return node; - }; - - // Cumulative share boundaries per day: boundaries[i][k] is the fraction - // of day i's answers at or below stack level k. - var boundaries = function(days, levels) { - return days.map(function(day) { - var total = 0; - return levels.map(function(level) { - total += day.counts[level.key] / day.n; - return Math.min(total, 1); - }); - }); - }; - - var timeScale = function(days, left, width) { - var times = days.map(function(day) { - return Date.parse(day.date + "T00:00:00Z"); - }); - var min = times[0]; - var span = times[times.length - 1] - min || 1; - return times.map(function(time) { - return left + ((time - min) / span) * width; - }); - }; - - // Date ticks at roughly 90px spacing, always including the first and - // last day; indices are deduplicated when the chart is narrow. - var tickIndexes = function(count, width) { - var target = Math.max(2, Math.min(count, Math.floor(width / 90) + 1)); - var indexes = []; - for (var i = 0; i < target; i++) { - var index = Math.round((i * (count - 1)) / (target - 1)); - if (indexes.indexOf(index) === -1) indexes.push(index); - } - return indexes; - }; - - var shortLabel = function(day) { - return day.label.replace(/,\s*\d{4}$/, "").replace(/\s+/g, " "); - }; - - var drawFrame = function(svg, geometry) { - [0, 0.5, 1].forEach(function(share) { - var y = geometry.y(share); - element("line", { - x1: MARGIN.left, x2: geometry.right, y1: y, y2: y, - stroke: GRID, "stroke-width": 1 - }, svg); - var label = element("text", { - x: MARGIN.left - 8, y: y + 3.5, - "text-anchor": "end", "font-size": 11, fill: TEXT - }, svg); - label.textContent = Math.round(share * 100) + "%"; - }); - }; - - var drawTicks = function(svg, geometry, days, xs) { - tickIndexes(days.length, geometry.right - MARGIN.left) - .forEach(function(index) { - var anchor = index === 0 ? "start" : - index === days.length - 1 ? "end" : "middle"; - var label = element("text", { - x: xs[index], y: geometry.bottom + 15, - "text-anchor": anchor, "font-size": 11, fill: TEXT - }, svg); - label.textContent = shortLabel(days[index]); - }); - }; - - var drawBands = function(svg, geometry, payload, xs, stacked) { - payload.levels.forEach(function(level, k) { - var upper = xs.map(function(x, i) { - return x + "," + geometry.y(stacked[i][k]); - }); - var lower = xs.map(function(x, i) { - return x + "," + geometry.y(k === 0 ? 0 : stacked[i][k - 1]); - }); - element("polygon", { - points: upper.concat(lower.reverse()).join(" "), - fill: level.color - }, svg); - }); - // Interior boundaries redrawn as surface-colored lines: the 2px gap - // that keeps neighboring bands apart without adding stroke ink. - for (var k = 0; k + 1 < payload.levels.length; k++) { - element("polyline", { - points: xs.map(function(x, i) { - return x + "," + geometry.y(stacked[i][k]); - }).join(" "), - fill: "none", stroke: SURFACE, "stroke-width": 2 - }, svg); - } - }; - - // A single dated day can't make an area; it gets one stacked column with - // the same gaps, rounded at the top of the stack, square at the baseline. - var drawColumn = function(svg, geometry, payload, x, stacked) { - var half = 12; - payload.levels.forEach(function(level, k) { - var top = geometry.y(stacked[0][k]); - var bottom = geometry.y(k === 0 ? 0 : stacked[0][k - 1]); - if (bottom - top < 0.5) return; - var gap = k === 0 ? 0 : 2; - var rounded = stacked[0][k] >= 1 - 1e-9; - element("path", { - d: rounded - ? "M" + (x - half) + " " + (bottom - gap) + - "V" + (top + 4) + - "Q" + (x - half) + " " + top + " " + (x - half + 4) + " " + top + - "H" + (x + half - 4) + - "Q" + (x + half) + " " + top + " " + (x + half) + " " + (top + 4) + - "V" + (bottom - gap) + "Z" - : "M" + (x - half) + " " + (bottom - gap) + - "V" + top + "H" + (x + half) + "V" + (bottom - gap) + "Z", - fill: level.color - }, svg); - }); - }; - - var buildTooltip = function(state, index) { - var day = state.payload.days[index]; - var tooltip = state.tooltip; - tooltip.textContent = ""; - var heading = document.createElement("div"); - heading.className = "commons-viewer-timeline-tooltip-date"; - heading.textContent = day.label.replace(/\s+/g, " ") + - " · " + day.n + (day.n === 1 ? " answer" : " answers"); - tooltip.appendChild(heading); - state.payload.levels.forEach(function(level) { - var row = document.createElement("div"); - row.className = "commons-viewer-timeline-tooltip-row"; - var swatch = document.createElement("span"); - swatch.className = "commons-viewer-timeline-swatch"; - swatch.style.background = level.color; - var value = document.createElement("strong"); - value.textContent = - Math.round((100 * day.counts[level.key]) / day.n) + "%"; - var label = document.createElement("span"); - label.textContent = level.label; - row.appendChild(swatch); - row.appendChild(value); - row.appendChild(label); - tooltip.appendChild(row); - }); - }; - - var showIndex = function(state, index) { - if (!state.geometry) return; - state.index = index; - var x = state.xs[index]; - state.crosshair.setAttribute("x1", x); - state.crosshair.setAttribute("x2", x); - state.crosshair.style.display = "block"; - buildTooltip(state, index); - var tooltip = state.tooltip; - tooltip.style.display = "block"; - var plotWidth = state.plot.clientWidth; - var width = tooltip.offsetWidth; - var left = x + 12 + width > plotWidth ? x - 12 - width : x + 12; - tooltip.style.left = Math.max(0, left) + "px"; - tooltip.style.top = MARGIN.top + "px"; - }; - - var hideIndex = function(state) { - state.index = null; - state.crosshair.style.display = "none"; - state.tooltip.style.display = "none"; - }; - - var nearestIndex = function(state, clientX) { - var offset = clientX - state.plot.getBoundingClientRect().left; - var best = 0; - state.xs.forEach(function(x, i) { - if (Math.abs(x - offset) < Math.abs(state.xs[best] - offset)) best = i; - }); - return best; - }; - - var drawTimeline = function(state) { - var plot = state.plot; - var payload = state.payload; - var width = plot.clientWidth; - var height = plot.clientHeight; - if (width <= MARGIN.left + MARGIN.right || height <= 0) return; - - var geometry = { - right: width - MARGIN.right, - bottom: height - MARGIN.bottom, - y: function(share) { - return MARGIN.top + - (1 - share) * (height - MARGIN.top - MARGIN.bottom); - } - }; - var stacked = boundaries(payload.days, payload.levels); - var xs = payload.days.length === 1 - ? [(MARGIN.left + geometry.right) / 2] - : timeScale(payload.days, MARGIN.left, geometry.right - MARGIN.left); - - plot.textContent = ""; - var svg = element("svg", { width: width, height: height }); - drawFrame(svg, geometry); - if (payload.days.length === 1) { - drawColumn(svg, geometry, payload, xs[0], stacked); - } else { - drawBands(svg, geometry, payload, xs, stacked); - } - drawTicks(svg, geometry, payload.days, xs); - state.crosshair = element("line", { - y1: MARGIN.top, y2: geometry.bottom, - stroke: TEXT, "stroke-width": 1, style: "display: none" - }, svg); - plot.appendChild(svg); - - state.geometry = geometry; - state.xs = xs; - if (state.index != null && state.index < payload.days.length) { - showIndex(state, state.index); - } - }; - - var attachPointer = function(state) { - state.plot.addEventListener("pointermove", function(event) { - showIndex(state, nearestIndex(state, event.clientX)); - }); - state.plot.addEventListener("pointerleave", function() { - hideIndex(state); - }); - state.plot.addEventListener("keydown", function(event) { - var last = state.payload.days.length - 1; - var moves = { - ArrowLeft: state.index == null ? last : Math.max(0, state.index - 1), - ArrowRight: state.index == null - ? 0 - : Math.min(last, state.index + 1), - Home: 0, - End: last - }; - if (event.key === "Escape") { - hideIndex(state); - } else if (event.key in moves) { - showIndex(state, moves[event.key]); - } else { - return; - } - event.preventDefault(); - }); - state.plot.addEventListener("blur", function() { - hideIndex(state); - }); - }; - - var initTimeline = function(container) { - if (container.commonsViewerTimeline) return; - container.commonsViewerTimeline = true; - var script = container.querySelector(".commons-viewer-timeline-data"); - var plot = container.querySelector(".commons-viewer-timeline-plot"); - if (!script || !plot) return; - var payload; - try { - payload = JSON.parse(script.textContent); - } catch (error) { - return; - } - if (!payload.days || !payload.days.length) return; - - var tooltip = document.createElement("div"); - tooltip.className = "commons-viewer-timeline-tooltip"; - container.appendChild(tooltip); - - var state = { - payload: payload, - plot: plot, - tooltip: tooltip, - index: null - }; - drawTimeline(state); - attachPointer(state); - new ResizeObserver(function() { - drawTimeline(state); - }).observe(plot); - }; - - var scan = function() { - document - .querySelectorAll(".commons-viewer-timeline") - .forEach(initTimeline); - }; - - // The timeline arrives with each renderUI flush; watch for it rather - // than hooking Shiny's (jQuery-only) render events. - var observe = function() { - if (!document.body) { - window.setTimeout(observe, 25); - return; - } - new MutationObserver(scan).observe(document.body, { - childList: true, - subtree: true - }); - scan(); - }; - - observe(); -})(); diff --git a/tests/testthat/test-view-trajectories.R b/tests/testthat/test-view-trajectories.R index 02870a7..db9e290 100644 --- a/tests/testthat/test-view-trajectories.R +++ b/tests/testthat/test-view-trajectories.R @@ -429,8 +429,9 @@ test_that("trust_timeline_days aggregates question tags by day", { expect_equal(windowed[[1]]$date, "2026-07-03") }) -test_that("trust_timeline renders a chart payload and its table view", { +test_that("trust_timeline renders a chart and its table view", { skip_if_not_installed("htmltools") + skip_if_not_installed("plotly") empty <- as.character(trust_timeline(list())) expect_match(empty, "No dated questions") @@ -440,18 +441,23 @@ test_that("trust_timeline renders a chart payload and its table view", { list(tag = "A", last_active = as.POSIXct("2026-07-02 09:00:00")), list(tag = "C", last_active = as.POSIXct("2026-07-02 16:00:00")) )) - html <- as.character(trust_timeline(days)) - json <- sub(".*]*>", "", html) - json <- sub(".*", "", json) - payload <- jsonlite::fromJSON(json, simplifyVector = FALSE) + # One stacked trace per trust level, sharing each day's answers out as + # percentages. + traces <- plotly::plotly_build(timeline_plot(days))$x$data + expect_length(traces, length(viewer_levels)) expect_equal( - vapply(payload$levels, function(level) level$key, character(1)), - c("A", "B", "C", "none") + vapply(traces, function(trace) trace$name, character(1)), + unname(viewer_levels) ) - expect_equal(payload$days[[2]]$n, 2) - expect_equal(payload$days[[2]]$counts$C, 1) + expect_equal(as.numeric(traces[[1]]$y), c(100, 50)) + expect_equal(as.numeric(traces[[3]]$y), c(0, 50)) + + # A single dated day charts as a stacked column instead of an area. + single <- plotly::plotly_build(timeline_plot(days[1]))$x$data + expect_equal(single[[1]]$type, "bar") + html <- as.character(trust_timeline(days)) # The table view carries every share the tooltip shows. expect_match(html, "commons-viewer-sr-only") expect_match(html, "50% (1)", fixed = TRUE) From 8fa65b968d8728b1558588fcb36a76b40d23eda1 Mon Sep 17 00:00:00 2001 From: skaltman Date: Mon, 3 Aug 2026 11:17:31 -0700 Subject: [PATCH 09/38] Add plotly as a dependency --- DESCRIPTION | 1 + 1 file changed, 1 insertion(+) diff --git a/DESCRIPTION b/DESCRIPTION index b9c7359..3f233a4 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -45,6 +45,7 @@ Suggests: otel (>= 0.2.0), otelsdk (>= 0.2.0), pins, + plotly, ragg, readr, rmarkdown, From ed4cc72672b7d4d84d7cdfb8ffa328c23492d9bd Mon Sep 17 00:00:00 2001 From: skaltman Date: Mon, 3 Aug 2026 12:04:09 -0700 Subject: [PATCH 10/38] Add resizable notes pane and allow conversation-level annotations --- R/view-trajectories.R | 69 ++++++++++++---------- inst/www/commons-viewer/commons-viewer.css | 66 ++++++++++++++++++--- inst/www/commons-viewer/commons-viewer.js | 58 ++++++++++++++++++ man/view_trajectories.Rd | 7 ++- tests/testthat/test-view-trajectories.R | 16 ++++- 5 files changed, 173 insertions(+), 43 deletions(-) diff --git a/R/view-trajectories.R b/R/view-trajectories.R index f6a9523..3518215 100644 --- a/R/view-trajectories.R +++ b/R/view-trajectories.R @@ -9,9 +9,10 @@ #' provenance pills the commons chat UI would show. #' #' Transcripts are reviewable rather than live: conversations and questions -#' can be flagged for review, and selecting a question-and-answer exchange -#' allows it to be annotated with notes. Both land in `review_file`, one JSON -#' record per line, and are restored when the viewer reopens. +#' can be flagged for review and annotated with notes. Notes apply to the +#' whole conversation, or to a single question-and-answer exchange selected +#' in the transcript. Flags and notes land in `review_file`, one JSON record +#' per line, and are restored when the viewer reopens. #' #' Trajectories carry no record of how each answer was tagged when it was #' produced, so the viewer derives trust levels from the tool calls in the @@ -434,6 +435,13 @@ viewer_ui <- function(summary) { class = "commons-viewer-transcript-pane", shiny::uiOutput("transcript", fill = TRUE) ), + htmltools::div( + class = "commons-viewer-pane-resizer", + role = "separator", + `aria-orientation` = "vertical", + `aria-label` = "Resize the notes pane", + tabindex = "0" + ), htmltools::div( class = "commons-viewer-review-pane", shiny::uiOutput("review_bar") @@ -557,16 +565,14 @@ viewer_server <- function(trajectories, summary, questions, review_file) { }) } + # With no exchange selected the pane works at conversation level: the + # flag and any notes cover the whole conversation. output$review_bar <- shiny::renderUI({ - navigation <- selected() - if (is.null(navigation)) { - return(NULL) - } - key <- review_target() - flagged <- selection_review_key(key %||% navigation, summary) %in% flags() + key <- review_target() %||% selected() if (is.null(key)) { - return(review_bar_prompt(flagged)) + return(NULL) } + flagged <- selection_review_key(key, summary) %in% flags() review_bar_notes(key, flagged, notes_for_selection(notes(), key, summary)) }) @@ -637,7 +643,7 @@ viewer_server <- function(trajectories, summary, questions, review_file) { ) shiny::observeEvent(input$save_note, { - key <- review_target() + key <- review_target() %||% selected() note <- trimws(input$review_note %||% "") if (is.null(key) || !nzchar(note)) { return() @@ -719,40 +725,41 @@ transcript_id <- function(key) { # Review ------------------------------------------------------------------ -# The review pane before an exchange is chosen; the flag applies to the -# whole conversation. -review_bar_prompt <- function(flagged) { - htmltools::div( - class = "commons-viewer-review", - htmltools::div( - class = "commons-viewer-review-bar", - htmltools::tags$strong("Notes"), - flag_button(flagged, TRUE) - ), - htmltools::div( - class = "commons-viewer-review-prompt", - "Select a question or answer in the transcript to add a note." - ) - ) -} - +# Without an exchange the pane annotates the whole conversation; selecting +# an exchange in the transcript scopes it to that question instead. review_bar_notes <- function(key, flagged, notes) { + whole_conversation <- is.null(key$exchange) htmltools::div( class = "commons-viewer-review", htmltools::div( class = "commons-viewer-review-bar", htmltools::tags$strong( - sprintf("Notes for Question %d", key$exchange) + if (whole_conversation) { + "Notes" + } else { + sprintf("Notes for Question %d", key$exchange) + } ), - flag_button(flagged, FALSE) + flag_button(flagged, whole_conversation) ), + if (whole_conversation) { + htmltools::div( + class = "commons-viewer-review-prompt", + "Notes here cover the whole conversation. Select a question or + answer in the transcript to note that exchange." + ) + }, review_note_list(notes), htmltools::div( class = "commons-viewer-note-compose", shiny::textAreaInput( "review_note", NULL, - placeholder = "Add a note about this exchange", + placeholder = if (whole_conversation) { + "Add a note about this conversation" + } else { + "Add a note about this exchange" + }, rows = 2, width = "100%" ), diff --git a/inst/www/commons-viewer/commons-viewer.css b/inst/www/commons-viewer/commons-viewer.css index c981445..3a7f5e1 100644 --- a/inst/www/commons-viewer/commons-viewer.css +++ b/inst/www/commons-viewer/commons-viewer.css @@ -103,10 +103,13 @@ /* ---- Review ------------------------------------------------------------ */ +/* The notes pane's width is draggable (commons-viewer.js sets the variable + * from the divider); until then it takes the responsive default. */ .commons-viewer-workspace { display: grid; flex: 1 1 auto; - grid-template-columns: minmax(0, 1fr) minmax(16rem, 22rem); + grid-template-columns: + minmax(0, 1fr) auto var(--commons-viewer-review-width, minmax(16rem, 22rem)); height: 100%; min-height: 0; } @@ -122,8 +125,35 @@ min-height: 0; } +/* The divider draws the panes' hairline rule down its center; the rest of + * its width is drag target. */ +.commons-viewer-pane-resizer { + background: linear-gradient( + to right, + transparent calc(50% - 0.5px), + var(--bs-border-color, #dee2e6) calc(50% - 0.5px), + var(--bs-border-color, #dee2e6) calc(50% + 0.5px), + transparent calc(50% + 0.5px) + ); + cursor: col-resize; + touch-action: none; + width: 0.5rem; +} + +.commons-viewer-pane-resizer:hover, +.commons-viewer-pane-resizer:focus-visible, +.commons-viewer-pane-resizing { + background: linear-gradient( + to right, + transparent calc(50% - 1px), + color-mix(in srgb, #007bc2 55%, var(--bs-body-bg, #fff)) calc(50% - 1px), + color-mix(in srgb, #007bc2 55%, var(--bs-body-bg, #fff)) calc(50% + 1px), + transparent calc(50% + 1px) + ); + outline: none; +} + .commons-viewer-review-pane { - border-left: 1px solid var(--bs-border-color, #dee2e6); min-width: 0; overflow-y: auto; } @@ -260,13 +290,29 @@ } .commons-viewer-exchange-message { + border-radius: 0.5rem; cursor: pointer; position: relative; + transition: background-color 0.15s ease; z-index: 1; } -.commons-viewer-exchange-message:hover { - background: color-mix(in srgb, #007bc2 5%, transparent); +.commons-viewer-exchange-message:not(.commons-viewer-exchange-selected):hover { + background-color: color-mix(in srgb, #007bc2 5%, transparent); +} + +/* The question bubble takes the hover tint into its gray rather than + * trading the gray for it, so hovering shades the bubble instead of + * swapping its color out. */ +.commons-viewer-transcript + .shiny-chat-user-message.commons-viewer-exchange-message:not( + .commons-viewer-exchange-selected + ):hover { + background-color: color-mix( + in srgb, + #007bc2 7%, + var(--shiny-chat-user-message-bg, #f4f5f7) + ); } .commons-viewer-exchange-message:focus-visible { @@ -287,8 +333,11 @@ z-index: 0; } -.commons-viewer-exchange-selected:hover { - background: transparent; +/* While selected, the question bubble cedes its gray to the highlight so + * the whole exchange reads as one blue region, pointer over it or not. */ +.commons-viewer-transcript + .shiny-chat-user-message.commons-viewer-exchange-selected { + background-color: transparent; } @media (max-width: 900px) { @@ -297,8 +346,11 @@ grid-template-rows: minmax(20rem, 1fr) auto; } + .commons-viewer-pane-resizer { + display: none; + } + .commons-viewer-review-pane { - border-left: 0; border-top: 1px solid var(--bs-border-color, #dee2e6); max-height: 40vh; } diff --git a/inst/www/commons-viewer/commons-viewer.js b/inst/www/commons-viewer/commons-viewer.js index 8fe364d..938d041 100644 --- a/inst/www/commons-viewer/commons-viewer.js +++ b/inst/www/commons-viewer/commons-viewer.js @@ -103,6 +103,64 @@ activateExchange(node); }); + // The divider between the transcript and the notes pane drags (and, for + // keyboard users, arrows) the pane's width, within bounds that keep both + // panes usable. + var setReviewWidth = function(workspace, width) { + var bounds = workspace.getBoundingClientRect(); + var min = 200; + var max = Math.max(min, bounds.width - 320); + width = Math.min(Math.max(width, min), max); + workspace.style.setProperty( + "--commons-viewer-review-width", + width + "px" + ); + }; + + var resizing = null; + document.addEventListener("pointerdown", function(event) { + if (!event.target || !event.target.closest) return; + var resizer = event.target.closest(".commons-viewer-pane-resizer"); + if (!resizer) return; + var workspace = resizer.closest(".commons-viewer-workspace"); + if (!workspace) return; + event.preventDefault(); + resizer.setPointerCapture(event.pointerId); + resizer.classList.add("commons-viewer-pane-resizing"); + resizing = { resizer: resizer, workspace: workspace }; + }); + + document.addEventListener("pointermove", function(event) { + if (!resizing) return; + var bounds = resizing.workspace.getBoundingClientRect(); + setReviewWidth(resizing.workspace, bounds.right - event.clientX); + }); + + var endResize = function() { + if (!resizing) return; + resizing.resizer.classList.remove("commons-viewer-pane-resizing"); + resizing = null; + }; + document.addEventListener("pointerup", endResize); + document.addEventListener("pointercancel", endResize); + + document.addEventListener("keydown", function(event) { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + if (!event.target || !event.target.closest) return; + var resizer = event.target.closest(".commons-viewer-pane-resizer"); + if (!resizer) return; + var workspace = resizer.closest(".commons-viewer-workspace"); + var pane = workspace && + workspace.querySelector(".commons-viewer-review-pane"); + if (!pane) return; + event.preventDefault(); + var step = event.key === "ArrowLeft" ? 16 : -16; + setReviewWidth( + workspace, + pane.getBoundingClientRect().width + step + ); + }); + // Server-driven selection state: review_target changes (including // deselection when navigation moves away) mirror into the transcript. Shiny.addCustomMessageHandler("commonsViewerExchangeSelect", function(message) { diff --git a/man/view_trajectories.Rd b/man/view_trajectories.Rd index 43fd126..b795a3f 100644 --- a/man/view_trajectories.Rd +++ b/man/view_trajectories.Rd @@ -33,9 +33,10 @@ filterable by date and trust level, and a transcript of each with the provenance pills the commons chat UI would show. Transcripts are reviewable rather than live: conversations and questions -can be flagged for review, and selecting a question-and-answer exchange -allows it to be annotated with notes. Both land in \code{review_file}, one JSON -record per line, and are restored when the viewer reopens. +can be flagged for review and annotated with notes. Notes apply to the +whole conversation, or to a single question-and-answer exchange selected +in the transcript. Flags and notes land in \code{review_file}, one JSON record +per line, and are restored when the viewer reopens. Trajectories carry no record of how each answer was tagged when it was produced, so the viewer derives trust levels from the tool calls in the diff --git a/tests/testthat/test-view-trajectories.R b/tests/testthat/test-view-trajectories.R index db9e290..645fcb5 100644 --- a/tests/testthat/test-view-trajectories.R +++ b/tests/testthat/test-view-trajectories.R @@ -384,23 +384,35 @@ test_that("flags and notes append to and restore from the review file", { notes_for_selection(notes(), review_target(), summary), notes() ) + + # With the exchange deselected, notes cover the whole conversation. + session$setInputs(exchange_select = list(nonce = 2)) + session$setInputs(review_note = "Reviewed end to end; looks fine.") + session$setInputs(save_note = 2) + expect_length(notes(), 2) + expect_null(notes()[[2]]$exchange) + expect_equal( + notes_for_selection(notes(), list(conversation = 1), summary), + notes()[2] + ) } ) records <- lapply(readLines(review_file), jsonlite::fromJSON) expect_equal( vapply(records, function(r) r$action, character(1)), - c("flag", "flag", "note") + c("flag", "flag", "note", "note") ) expect_equal(records[[2]]$conversation, "conv1") expect_equal(records[[2]]$exchange, 1) expect_equal(records[[3]]$note, "Wrong join, should use orders.") + expect_null(records[[4]]$exchange) restored <- read_review_records(review_file) expect_equal(review_flags(restored), c("conv1", "conv1#1")) expect_equal( vapply(review_notes(restored), `[[`, character(1), "note"), - "Wrong join, should use orders." + c("Wrong join, should use orders.", "Reviewed end to end; looks fine.") ) }) From c5e1faa5a3d96f59f1b201d251a20d435cfe0425 Mon Sep 17 00:00:00 2001 From: skaltman Date: Mon, 3 Aug 2026 12:13:52 -0700 Subject: [PATCH 11/38] Increase border-radius of exchange message boxes from 0.5rem to 1rem --- inst/www/commons-viewer/commons-viewer.css | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/inst/www/commons-viewer/commons-viewer.css b/inst/www/commons-viewer/commons-viewer.css index 3a7f5e1..8eda470 100644 --- a/inst/www/commons-viewer/commons-viewer.css +++ b/inst/www/commons-viewer/commons-viewer.css @@ -290,7 +290,7 @@ } .commons-viewer-exchange-message { - border-radius: 0.5rem; + border-radius: 1rem; cursor: pointer; position: relative; transition: background-color 0.15s ease; @@ -322,9 +322,12 @@ /* Selected (9%) sits above hover (5%): over a whole exchange even a light * wash carries plenty of weight. */ +/* Rounder than the sidebar entries' 0.5rem: on a box this large a small + * radius reads as square, so the radius scales with the box to match the + * sidebar's perceived roundness. */ .commons-viewer-exchange-highlight { background: color-mix(in srgb, #007bc2 9%, transparent); - border-radius: 0.5rem; + border-radius: 1rem; display: none; left: 0; pointer-events: none; From a92b3bbfa60ff672535503cd4ed753e681b70982 Mon Sep 17 00:00:00 2001 From: skaltman Date: Mon, 3 Aug 2026 12:29:46 -0700 Subject: [PATCH 12/38] Implement adaptive time binning for timeline chart --- R/view-trajectories.R | 163 ++++++++++++++------- inst/www/commons-viewer/commons-viewer.css | 8 - man/view_trajectories.Rd | 9 +- 3 files changed, 115 insertions(+), 65 deletions(-) diff --git a/R/view-trajectories.R b/R/view-trajectories.R index 3518215..bf65472 100644 --- a/R/view-trajectories.R +++ b/R/view-trajectories.R @@ -3,10 +3,11 @@ #' @description #' `view_trajectories()` launches a Shiny app for browsing conversation #' trajectories read with [read_trajectories()]. The app charts each trust -#' level's share of answers over time—with the overall rates in the chart's -#' legend—alongside a list of conversations or of individual questions, -#' filterable by date and trust level, and a transcript of each with the -#' provenance pills the commons chat UI would show. +#' level's share of answers over time—binned by day, week, or month, using +#' the finest unit the volume of answers supports—alongside a list of +#' conversations or of individual questions, filterable by date and trust +#' level, and a transcript of each with the provenance pills the commons +#' chat UI would show. #' #' Transcripts are reviewable rather than live: conversations and questions #' can be flagged for review and annotated with notes. Notes apply to the @@ -509,9 +510,9 @@ viewer_server <- function(trajectories, summary, questions, review_file) { }) # The timeline reflects the date window but not the trust filter: it - # charts the trust distribution the filter slices. Its legend carries - # the window's overall rates -- including undated answers, which the - # per-day bands can't place. + # charts the trust distribution the filter slices. The legend's entry + # tooltips carry the window's overall rates -- including undated + # answers, which the per-bin bands can't place. output$timeline_legend <- shiny::renderUI({ in_dates <- Filter( function(i) in_window(summary[[i]], input$window), @@ -521,7 +522,7 @@ viewer_server <- function(trajectories, summary, questions, review_file) { }) output$timeline <- shiny::renderUI({ - trust_timeline(trust_timeline_days(questions, input$window)) + trust_timeline(trust_timeline_bins(questions, input$window)) }) output$entries <- shiny::renderUI({ @@ -1056,9 +1057,9 @@ trust_timeline_card <- function() { ) } -# The legend doubles as the viewer's headline hit rate: each level's -# window-wide share of answers rides its legend entry, with the counts -# behind the percentage in the entry's tooltip. +# The legend is a plain key for the chart's colors; each level's +# window-wide share sits one hover away, in its entry's tooltip, rather +# than inline where it read as part of the chart. timeline_legend <- function(rate) { htmltools::div( class = "commons-viewer-timeline-legend", @@ -1066,25 +1067,28 @@ timeline_legend <- function(rate) { htmltools::tags$span( class = "commons-viewer-timeline-legend-item", title = sprintf( - "%d of %d answers", + "%d of %d answers (%s)", rate$counts[[key]], - rate$n + rate$n, + rate_percent(rate$counts[[key]], rate$n) ), htmltools::tags$span( class = "commons-viewer-timeline-swatch", style = paste0("background:", viewer_level_colors[[key]]) ), - viewer_levels[[key]], - htmltools::tags$strong(rate_percent(rate$counts[[key]], rate$n)) + viewer_levels[[key]] ) }) ) } -# Per-day tag counts for the dated questions inside the window, in date +# Per-bin tag counts for the dated questions inside the window, in date # order. Undated questions have no x position, so the chart skips them; -# the legend still counts them. -trust_timeline_days <- function(questions, window = NULL) { +# the legend still counts them. Bins are days, weeks, or months -- the +# finest unit whose bins hold `target` answers on average -- so a sparse +# store charts a few honest aggregates rather than a per-day sawtooth of +# one-answer days swinging between 0% and 100%. +trust_timeline_bins <- function(questions, window = NULL, target = 5) { dates <- as.Date(vapply( questions, function(record) as.character(local_date(record$last_active)), @@ -1097,15 +1101,17 @@ trust_timeline_days <- function(questions, window = NULL) { questions <- questions[keep] dates <- dates[keep] - lapply(sort(unique(dates)), function(day) { + unit <- timeline_bin_unit(dates, target) + starts <- timeline_bin_start(dates, unit) + bins <- lapply(sort(unique(starts)), function(start) { tags <- vapply( - questions[dates == day], + questions[starts == start], function(record) record$tag, character(1) ) list( - date = format(day, "%Y-%m-%d"), - label = format(day, "%b %e, %Y"), + date = format(start, "%Y-%m-%d"), + label = timeline_bin_label(start, unit), n = length(tags), counts = list( A = sum(tags %in% "A"), @@ -1115,13 +1121,45 @@ trust_timeline_days <- function(questions, window = NULL) { ) ) }) + list(unit = unit, bins = bins) +} + +# Multiplication rather than mean(): zero dates make day's 0 >= 0 true, so +# an empty window stays on the day unit instead of dividing by zero. +timeline_bin_unit <- function(dates, target) { + for (unit in c("day", "week")) { + bins <- unique(timeline_bin_start(dates, unit)) + if (length(dates) >= target * length(bins)) { + return(unit) + } + } + "month" +} + +# Weeks start on Monday (ISO), months on the first. +timeline_bin_start <- function(dates, unit) { + switch( + unit, + day = dates, + week = dates - (as.integer(format(dates, "%u")) - 1L), + month = as.Date(format(dates, "%Y-%m-01")) + ) +} + +timeline_bin_label <- function(start, unit) { + switch( + unit, + day = format(start, "%b %e, %Y"), + week = paste("Week of", format(start, "%b %e, %Y")), + month = format(start, "%B %Y") + ) } # The table is the same data as the chart, readable without a pointer, and # is where screen readers land instead of the drawing: role="img" on the # plot's wrapper keeps plotly's internals out of the accessibility tree. -trust_timeline <- function(days) { - if (length(days) == 0) { +trust_timeline <- function(binned) { + if (length(binned$bins) == 0) { return(viewer_empty_note("No dated questions in this date range.")) } htmltools::div( @@ -1129,39 +1167,47 @@ trust_timeline <- function(days) { htmltools::div( class = "commons-viewer-timeline-plot", role = "img", - `aria-label` = paste( - "Chart of the share of answers at each trust level by day.", - "The values appear in the table that follows." + `aria-label` = sprintf( + "Chart of the share of answers at each trust level by %s. + The values appear in the table that follows.", + binned$unit ), - timeline_plot(days) + timeline_plot(binned$bins, binned$unit) ), - timeline_table(days) + timeline_table(binned$bins) ) } -# A 100%-stacked area chart of each day's trust-level shares -- one stacked -# column when only one day is dated, since a single point can't make an +# A 100%-stacked area chart of each bin's trust-level shares -- one stacked +# column when only one bin is dated, since a single point can't make an # area. Unified hover fires anywhere in the fills and mirrors the app's -# tooltip styling: a date header, then a swatch row per level with the -# share in bold. The card header's legend names the levels, so the -# widget's own legend stays off. -timeline_plot <- function(days) { - dates <- as.Date(vapply(days, function(day) day$date, character(1))) - n <- vapply(days, function(day) day$n, numeric(1)) +# tooltip styling: a bin header, then a swatch row per level with the +# share in bold, closing with the bin's answer count so a reader can judge +# how much weight the shares deserve. The card header's legend names the +# levels, so the widget's own legend stays off. +timeline_plot <- function(bins, unit) { + dates <- as.Date(vapply(bins, function(bin) bin$date, character(1))) + n <- vapply(bins, function(bin) bin$n, numeric(1)) plot <- plotly::plot_ly(height = 176) for (k in seq_along(viewer_levels)) { key <- names(viewer_levels)[[k]] - counts <- vapply(days, function(day) day$counts[[key]], numeric(1)) + counts <- vapply(bins, function(bin) bin$counts[[key]], numeric(1)) shares <- 100 * counts / n + # Unified hover lists stacked traces top band first, so the sample + # size rides the baseline trace: it reads as the tooltip's last line. hovertemplate <- paste0( - "%{y:.0f}% ", viewer_levels[[key]], "" + "%{y:.0f}% ", + viewer_levels[[key]], + if (k == 1) "
(n = %{customdata})", + "" ) - plot <- if (length(days) == 1) { + plot <- if (length(bins) == 1) { plotly::add_bars( plot, x = dates, y = shares, + customdata = n, name = unname(viewer_levels[[key]]), hovertemplate = hovertemplate, marker = list(color = viewer_level_colors[[key]]), @@ -1174,6 +1220,7 @@ timeline_plot <- function(days) { plot, x = dates, y = shares, + customdata = n, name = unname(viewer_levels[[key]]), hovertemplate = hovertemplate, type = "scatter", @@ -1191,10 +1238,10 @@ timeline_plot <- function(days) { } } - # Ticks sit on dated days themselves rather than plotly's auto ticks, + # Ticks sit on dated bins themselves rather than plotly's auto ticks, # which land on empty dates between them and wrap into two lines ("Jul 2" - # over "2026") that the bottom margin can't fit: up to seven days, always - # including the first and last, as single-line month-day labels. + # over "2026") that the bottom margin can't fit: up to seven bins, always + # including the first and last, as single-line labels. ticks <- unique(round(seq( 1, length(dates), @@ -1223,12 +1270,22 @@ timeline_plot <- function(days) { # Unified hover draws a spike line down to the axis by default; the # tooltip alone is enough. showspikes = FALSE, - hoverformat = "%b %e, %Y", + # d3 time formats pass literals through, so a week bin's header + # names itself ("Week of Jul 20, 2026"). + hoverformat = switch( + unit, + day = "%b %e, %Y", + week = "Week of %b %e, %Y", + month = "%B %Y" + ), tickvals = as.list(format(dates[ticks])), - ticktext = as.list(format(dates[ticks], "%b %e")), - # A lone day gives autorange nothing but the column's own edges to + ticktext = as.list(format( + dates[ticks], + if (identical(unit, "month")) "%b %Y" else "%b %e" + )), + # A lone bin gives autorange nothing but the column's own edges to # work with, so it would stretch the column across the card. - range = if (length(days) == 1) as.list(format(dates + c(-1, 1))) + range = if (length(bins) == 1) as.list(format(dates + c(-1, 1))) ), yaxis = list( title = FALSE, @@ -1243,18 +1300,18 @@ timeline_plot <- function(days) { plotly::config(plot, displayModeBar = FALSE, responsive = TRUE) } -timeline_table <- function(days) { - rows <- lapply(days, function(day) { +timeline_table <- function(bins) { + rows <- lapply(bins, function(bin) { htmltools::tags$tr( - htmltools::tags$td(day$label), + htmltools::tags$td(bin$label), lapply(names(viewer_levels), function(key) { htmltools::tags$td(sprintf( "%s (%d)", - rate_percent(day$counts[[key]], day$n), - day$counts[[key]] + rate_percent(bin$counts[[key]], bin$n), + bin$counts[[key]] )) }), - htmltools::tags$td(day$n) + htmltools::tags$td(bin$n) ) }) htmltools::tags$table( diff --git a/inst/www/commons-viewer/commons-viewer.css b/inst/www/commons-viewer/commons-viewer.css index 8eda470..04a9c62 100644 --- a/inst/www/commons-viewer/commons-viewer.css +++ b/inst/www/commons-viewer/commons-viewer.css @@ -64,14 +64,6 @@ gap: 0.35rem; } -/* The window-wide rate reads as the entry's value: body ink, tabular so - * the row doesn't shimmy as the date window moves. */ -.commons-viewer-timeline-legend-item strong { - color: var(--bs-body-color, #212529); - font-variant-numeric: tabular-nums; - font-weight: 600; -} - .commons-viewer-timeline-swatch { border-radius: 2px; display: inline-block; diff --git a/man/view_trajectories.Rd b/man/view_trajectories.Rd index b795a3f..8d46ee4 100644 --- a/man/view_trajectories.Rd +++ b/man/view_trajectories.Rd @@ -27,10 +27,11 @@ expression of an \code{app.R}. \description{ \code{view_trajectories()} launches a Shiny app for browsing conversation trajectories read with \code{\link[=read_trajectories]{read_trajectories()}}. The app charts each trust -level's share of answers over time—with the overall rates in the chart's -legend—alongside a list of conversations or of individual questions, -filterable by date and trust level, and a transcript of each with the -provenance pills the commons chat UI would show. +level's share of answers over time—binned by day, week, or month, using +the finest unit the volume of answers supports—alongside a list of +conversations or of individual questions, filterable by date and trust +level, and a transcript of each with the provenance pills the commons +chat UI would show. Transcripts are reviewable rather than live: conversations and questions can be flagged for review and annotated with notes. Notes apply to the From 143e08a328768837fcd9598d7312f52db7a43853 Mon Sep 17 00:00:00 2001 From: skaltman Date: Mon, 3 Aug 2026 13:05:34 -0700 Subject: [PATCH 13/38] Refactor timeline tooltip to render as per-bin card instead of unified hover --- R/view-trajectories.R | 73 ++++++++++------ tests/testthat/test-view-trajectories.R | 111 +++++++++++++++++------- 2 files changed, 123 insertions(+), 61 deletions(-) diff --git a/R/view-trajectories.R b/R/view-trajectories.R index bf65472..1af045c 100644 --- a/R/view-trajectories.R +++ b/R/view-trajectories.R @@ -1180,36 +1180,33 @@ trust_timeline <- function(binned) { # A 100%-stacked area chart of each bin's trust-level shares -- one stacked # column when only one bin is dated, since a single point can't make an -# area. Unified hover fires anywhere in the fills and mirrors the app's -# tooltip styling: a bin header, then a swatch row per level with the -# share in bold, closing with the bin's answer count so a reader can judge -# how much weight the shares deserve. The card header's legend names the -# levels, so the widget's own legend stays off. +# area. The tooltip is one card drawn wholly from per-bin text: unified +# hover's date header can't carry the bin's n beside the date, so the top +# trace renders header and swatch rows itself and the traces beneath skip +# hover. The card header's legend names the levels, so the widget's own +# legend stays off. timeline_plot <- function(bins, unit) { dates <- as.Date(vapply(bins, function(bin) bin$date, character(1))) n <- vapply(bins, function(bin) bin$n, numeric(1)) + tooltips <- vapply(bins, timeline_tooltip, character(1)) plot <- plotly::plot_ly(height = 176) for (k in seq_along(viewer_levels)) { key <- names(viewer_levels)[[k]] counts <- vapply(bins, function(bin) bin$counts[[key]], numeric(1)) shares <- 100 * counts / n - # Unified hover lists stacked traces top band first, so the sample - # size rides the baseline trace: it reads as the tooltip's last line. - hovertemplate <- paste0( - "%{y:.0f}% ", - viewer_levels[[key]], - if (k == 1) "
(n = %{customdata})", - "" - ) + top <- k == length(viewer_levels) plot <- if (length(bins) == 1) { plotly::add_bars( plot, x = dates, y = shares, - customdata = n, name = unname(viewer_levels[[key]]), - hovertemplate = hovertemplate, + text = if (top) tooltips, + # Bars would print their text on the bar itself. + textposition = "none", + hovertemplate = if (top) "%{text}", + hoverinfo = if (!top) "skip", marker = list(color = viewer_level_colors[[key]]), # About two hours wide, in the date axis's milliseconds; with the # axis pinned a day either side, a column rather than a fill. @@ -1220,9 +1217,10 @@ timeline_plot <- function(bins, unit) { plot, x = dates, y = shares, - customdata = n, name = unname(viewer_levels[[key]]), - hovertemplate = hovertemplate, + text = if (top) tooltips, + hovertemplate = if (top) "%{text}", + hoverinfo = if (!top) "skip", type = "scatter", mode = "lines", stackgroup = "levels", @@ -1232,7 +1230,7 @@ timeline_plot <- function(bins, unit) { # edge and draws nothing. line = list( color = "#ffffff", - width = if (k == length(viewer_levels)) 0 else 2 + width = if (top) 0 else 2 ) ) } @@ -1251,8 +1249,12 @@ timeline_plot <- function(bins, unit) { plot <- plotly::layout( plot, barmode = "stack", - hovermode = "x unified", + # Hover snaps to the nearest bin's x with no pixel cutoff, so the one + # text-bearing trace fires anywhere in the fills, as unified hover did. + hovermode = "x", + hoverdistance = -1, hoverlabel = list( + align = "left", bgcolor = "#ffffff", bordercolor = "#dee2e6", font = list(size = 12, color = "#212529") @@ -1267,17 +1269,9 @@ timeline_plot <- function(bins, unit) { type = "date", showgrid = FALSE, fixedrange = TRUE, - # Unified hover draws a spike line down to the axis by default; the + # Hovering draws a spike line down to the axis by default; the # tooltip alone is enough. showspikes = FALSE, - # d3 time formats pass literals through, so a week bin's header - # names itself ("Week of Jul 20, 2026"). - hoverformat = switch( - unit, - day = "%b %e, %Y", - week = "Week of %b %e, %Y", - month = "%B %Y" - ), tickvals = as.list(format(dates[ticks])), ticktext = as.list(format( dates[ticks], @@ -1300,6 +1294,29 @@ timeline_plot <- function(bins, unit) { plotly::config(plot, displayModeBar = FALSE, responsive = TRUE) } +# One bin's hover card, in plotly's pseudo-HTML: the bin and its answer +# count on the header line, then a swatch row per level in legend order. +# The swatches are colored text glyphs -- plotly hover text supports +# color via span styles, but no real markup. +timeline_tooltip <- function(bin) { + rows <- vapply( + names(viewer_levels), + function(key) { + sprintf( + "\u25a0 %s %s", + viewer_level_colors[[key]], + rate_percent(bin$counts[[key]], bin$n), + viewer_levels[[key]] + ) + }, + character(1) + ) + paste( + c(sprintf("%s (n = %d)", bin$label, bin$n), rows), + collapse = "
" + ) +} + timeline_table <- function(bins) { rows <- lapply(bins, function(bin) { htmltools::tags$tr( diff --git a/tests/testthat/test-view-trajectories.R b/tests/testthat/test-view-trajectories.R index 645fcb5..ed65803 100644 --- a/tests/testthat/test-view-trajectories.R +++ b/tests/testthat/test-view-trajectories.R @@ -416,66 +416,111 @@ test_that("flags and notes append to and restore from the review file", { ) }) -test_that("trust_timeline_days aggregates question tags by day", { - questions <- list( - list(tag = "A", last_active = as.POSIXct("2026-07-01 09:00:00")), - list(tag = "C", last_active = as.POSIXct("2026-07-01 15:00:00")), - list(tag = NA_character_, last_active = as.POSIXct("2026-07-03 10:00:00")), - list(tag = "B", last_active = as.POSIXct(NA)) +timeline_question <- function(tag, date) { + list(tag = tag, last_active = as.POSIXct(paste(date, "09:00:00"))) +} + +test_that("trust_timeline_bins aggregates question tags by day at volume", { + questions <- c( + lapply(c("A", "A", "A", "A", "C"), timeline_question, "2026-07-01"), + lapply(c("A", "C", "C", "C", NA), timeline_question, "2026-07-03"), + list(list(tag = "B", last_active = as.POSIXct(NA))) ) - days <- trust_timeline_days(questions) + binned <- trust_timeline_bins(questions) + bins <- binned$bins # The undated question has no x position, so only two days chart. - expect_length(days, 2) - expect_equal(days[[1]]$date, "2026-07-01") - expect_equal(days[[1]]$n, 2) - expect_equal(days[[1]]$counts, list(A = 1L, B = 0L, C = 1L, none = 0L)) - expect_equal(days[[2]]$counts, list(A = 0L, B = 0L, C = 0L, none = 1L)) - - windowed <- trust_timeline_days( + expect_equal(binned$unit, "day") + expect_length(bins, 2) + expect_equal(bins[[1]]$date, "2026-07-01") + expect_equal(bins[[1]]$n, 5) + expect_equal(bins[[1]]$counts, list(A = 4L, B = 0L, C = 1L, none = 0L)) + expect_equal(bins[[2]]$counts, list(A = 1L, B = 0L, C = 3L, none = 1L)) + + windowed <- trust_timeline_bins( questions, as.Date(c("2026-07-02", "2026-07-04")) ) - expect_length(windowed, 1) - expect_equal(windowed[[1]]$date, "2026-07-03") + expect_length(windowed$bins, 1) + expect_equal(windowed$bins[[1]]$date, "2026-07-03") +}) + +test_that("trust_timeline_bins widens bins until they hold enough answers", { + # Two answers a day averages under five per day but over five per + # Monday-start week; Thursday July 2, 2026 through Wednesday July 8 + # spans the weeks of Monday June 29 and Monday July 6. + weekly <- unlist( + lapply(as.character(seq(as.Date("2026-07-02"), by = 1, length.out = 7)), { + function(date) lapply(c("A", "C"), timeline_question, date) + }), + recursive = FALSE + ) + binned <- trust_timeline_bins(weekly) + expect_equal(binned$unit, "week") + expect_length(binned$bins, 2) + expect_equal(binned$bins[[1]]$date, "2026-06-29") + expect_equal(binned$bins[[1]]$label, "Week of Jun 29, 2026") + expect_equal(binned$bins[[1]]$n, 8) + + # One answer a week doesn't fill weeks either, so bins become months. + monthly <- lapply( + as.character(seq(as.Date("2026-06-01"), by = 7, length.out = 9)), + timeline_question, + tag = "A" + ) + binned <- trust_timeline_bins(monthly) + expect_equal(binned$unit, "month") + expect_equal( + vapply(binned$bins, function(bin) bin$label, character(1)), + c("June 2026", "July 2026") + ) + + expect_equal(trust_timeline_bins(list())$unit, "day") + expect_length(trust_timeline_bins(list())$bins, 0) }) test_that("trust_timeline renders a chart and its table view", { skip_if_not_installed("htmltools") skip_if_not_installed("plotly") - empty <- as.character(trust_timeline(list())) + empty <- as.character(trust_timeline(trust_timeline_bins(list()))) expect_match(empty, "No dated questions") - days <- trust_timeline_days(list( - list(tag = "A", last_active = as.POSIXct("2026-07-01 09:00:00")), - list(tag = "A", last_active = as.POSIXct("2026-07-02 09:00:00")), - list(tag = "C", last_active = as.POSIXct("2026-07-02 16:00:00")) + binned <- trust_timeline_bins(c( + lapply(rep("A", 5), timeline_question, "2026-07-01"), + lapply(c("A", "A", "A", "C", "C"), timeline_question, "2026-07-02") )) + bins <- binned$bins - # One stacked trace per trust level, sharing each day's answers out as - # percentages. - traces <- plotly::plotly_build(timeline_plot(days))$x$data + # One stacked trace per trust level, sharing each bin's answers out as + # percentages; the top trace draws the whole hover card, with the bin's + # n up beside the date, and the others skip hover. + traces <- plotly::plotly_build(timeline_plot(bins, binned$unit))$x$data expect_length(traces, length(viewer_levels)) expect_equal( vapply(traces, function(trace) trace$name, character(1)), unname(viewer_levels) ) - expect_equal(as.numeric(traces[[1]]$y), c(100, 50)) - expect_equal(as.numeric(traces[[3]]$y), c(0, 50)) + expect_equal(as.numeric(traces[[1]]$y), c(100, 60)) + expect_equal(as.numeric(traces[[3]]$y), c(0, 40)) + top <- traces[[length(traces)]] + expect_true(all(top$hovertemplate == "%{text}")) + expect_match(top$text[[1]], "Jul 1, 2026 (n = 5)", fixed = TRUE) + expect_match(top$text[[2]], "60% Verified", fixed = TRUE) + expect_true(all(traces[[1]]$hoverinfo == "skip")) - # A single dated day charts as a stacked column instead of an area. - single <- plotly::plotly_build(timeline_plot(days[1]))$x$data + # A single dated bin charts as a stacked column instead of an area. + single <- plotly::plotly_build(timeline_plot(bins[1], binned$unit))$x$data expect_equal(single[[1]]$type, "bar") - html <- as.character(trust_timeline(days)) + html <- as.character(trust_timeline(binned)) # The table view carries every share the tooltip shows. expect_match(html, "commons-viewer-sr-only") - expect_match(html, "50% (1)", fixed = TRUE) + expect_match(html, "60% (3)", fixed = TRUE) }) -test_that("the timeline legend carries each level's window-wide rate", { +test_that("the timeline legend tucks each level's rate into its tooltip", { skip_if_not_installed("htmltools") legend <- as.character(timeline_legend( @@ -483,8 +528,8 @@ test_that("the timeline legend carries each level's window-wide rate", { )) expect_match(legend, "Verified") - expect_match(legend, "25%", fixed = TRUE) - expect_match(legend, "1 of 4 answers") + expect_match(legend, "1 of 4 answers (25%)", fixed = TRUE) + expect_no_match(legend, "", fixed = TRUE) empty <- as.character(timeline_legend(hit_rate(list()))) expect_match(empty, "—") From 32e8fc49bf26ec13e4dd58890222ab3a9f073ee0 Mon Sep 17 00:00:00 2001 From: skaltman Date: Mon, 3 Aug 2026 13:15:14 -0700 Subject: [PATCH 14/38] Auto-scroll to selected exchange when opening conversation --- inst/www/commons-viewer/commons-viewer.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/inst/www/commons-viewer/commons-viewer.js b/inst/www/commons-viewer/commons-viewer.js index 938d041..e56f7c2 100644 --- a/inst/www/commons-viewer/commons-viewer.js +++ b/inst/www/commons-viewer/commons-viewer.js @@ -203,6 +203,17 @@ ); }); selectExchange(chat, message.selected); + // A question entry opens the whole conversation; slide its + // exchange to the top of the view. + if (message.selected != null) { + var target = chat.querySelector( + '.commons-viewer-exchange-message[data-exchange="' + + message.selected + '"]' + ); + if (target) { + target.scrollIntoView({ behavior: "smooth", block: "start" }); + } + } }; seed(); From 0de211a97df58cc656cb220a709490bcfef45591 Mon Sep 17 00:00:00 2001 From: skaltman Date: Mon, 3 Aug 2026 13:15:31 -0700 Subject: [PATCH 15/38] Simplify trajectory transcript handling and improve timeline binning window bounds --- R/view-trajectories.R | 173 +++++++++++++++++------- tests/testthat/test-view-trajectories.R | 44 ++++-- 2 files changed, 158 insertions(+), 59 deletions(-) diff --git a/R/view-trajectories.R b/R/view-trajectories.R index 1af045c..b82f7f1 100644 --- a/R/view-trajectories.R +++ b/R/view-trajectories.R @@ -240,20 +240,18 @@ hit_rate <- function(tag_sets) { # Tool requests are dropped, mirroring shinychat's own transcript restore # (each result card carries its request). `count` and `indexFromEnd` index # assistant messages, which is what the seed handler counts. -trajectory_transcript <- function(turns, exchange_numbers = NULL) { +trajectory_transcript <- function(turns) { exchanges <- split_exchanges(turns) - exchange_numbers <- exchange_numbers %||% seq_along(exchanges) messages <- list() pills <- list() n_assistant <- 0L for (i in seq_along(exchanges)) { exchange <- exchanges[[i]] - exchange_number <- exchange_numbers[[i]] messages[[length(messages) + 1]] <- list( role = "user", content = exchange[[1]]@text, - exchange = exchange_number + exchange = i ) chunks <- exchange_answer_chunks(exchange[-1]) if (length(chunks) == 0) { @@ -263,7 +261,7 @@ trajectory_transcript <- function(turns, exchange_numbers = NULL) { messages[[length(messages) + 1]] <- list( role = "assistant", content = chunks, - exchange = exchange_number + exchange = i ) pill <- viewer_pill(exchange_provenance(exchange), n_assistant) if (!is.null(pill)) { @@ -330,10 +328,9 @@ restore_transcript <- function( session, id, turns, - exchange_numbers = NULL, selected_exchange = NULL ) { - transcript <- trajectory_transcript(turns, exchange_numbers) + transcript <- trajectory_transcript(turns) for (message in transcript$messages) { if (identical(message$role, "user")) { shinychat::chat_append_message( @@ -567,9 +564,11 @@ viewer_server <- function(trajectories, summary, questions, review_file) { } # With no exchange selected the pane works at conversation level: the - # flag and any notes cover the whole conversation. + # flag and any notes cover the whole conversation. The fallback strips + # any exchange from the navigation key, so deselecting an exchange + # lands at conversation level from a question entry too. output$review_bar <- shiny::renderUI({ - key <- review_target() %||% selected() + key <- review_target() %||% selected()["conversation"] if (is.null(key)) { return(NULL) } @@ -578,7 +577,7 @@ viewer_server <- function(trajectories, summary, questions, review_file) { }) shiny::observeEvent(input$flag_toggle, { - key <- review_target() %||% selected() + key <- review_target() %||% selected()["conversation"] review <- selection_review_key(key, summary) flagged <- review %in% flags() append_review_record( @@ -644,7 +643,7 @@ viewer_server <- function(trajectories, summary, questions, review_file) { ) shiny::observeEvent(input$save_note, { - key <- review_target() %||% selected() + key <- review_target() %||% selected()["conversation"] note <- trimws(input$review_note %||% "") if (is.null(key) || !nzchar(note)) { return() @@ -670,6 +669,9 @@ viewer_server <- function(trajectories, summary, questions, review_file) { # onFlushed fires after the flush that delivers the new chat element, so # it is bound client-side before the replayed messages arrive -- the same # mechanism commons_server() uses to seed pills. + # Question and conversation entries open the same thing -- the whole + # conversation -- a question entry just arrives with its exchange + # selected and scrolled into view. shiny::observeEvent(selected(), { key <- selected() session$onFlushed( @@ -677,8 +679,7 @@ viewer_server <- function(trajectories, summary, questions, review_file) { restore_transcript( session, transcript_id(key), - selected_turns(trajectories, key), - exchange_numbers = key$exchange, + trajectories[[key$conversation]], selected_exchange = key$exchange ) }, @@ -706,16 +707,6 @@ tag_matches <- function(tags, trust) { ) } -# A question selection restores only its own exchange; a conversation -# selection restores the whole transcript. -selected_turns <- function(trajectories, key) { - turns <- trajectories[[key$conversation]] - if (is.null(key$exchange)) { - return(turns) - } - split_exchanges(turns)[[key$exchange]] -} - entry_link_id <- function(key) { paste(c("entry", key$conversation, key$exchange), collapse = "_") } @@ -1101,7 +1092,8 @@ trust_timeline_bins <- function(questions, window = NULL, target = 5) { questions <- questions[keep] dates <- dates[keep] - unit <- timeline_bin_unit(dates, target) + bounds <- timeline_bounds(window, dates) + unit <- timeline_bin_unit(dates, bounds, target) starts <- timeline_bin_start(dates, unit) bins <- lapply(sort(unique(starts)), function(start) { tags <- vapply( @@ -1110,8 +1102,10 @@ trust_timeline_bins <- function(questions, window = NULL, target = 5) { character(1) ) list( - date = format(start, "%Y-%m-%d"), - label = timeline_bin_label(start, unit), + # A bin the window enters midway charts at the window's edge, not at + # a calendar boundary outside it. + date = format(max(start, bounds[[1]]), "%Y-%m-%d"), + label = timeline_bin_label(start, unit, bounds), n = length(tags), counts = list( A = sum(tags %in% "A"), @@ -1124,16 +1118,37 @@ trust_timeline_bins <- function(questions, window = NULL, target = 5) { list(unit = unit, bins = bins) } -# Multiplication rather than mean(): zero dates make day's 0 >= 0 true, so -# an empty window stays on the day unit instead of dividing by zero. -timeline_bin_unit <- function(dates, target) { - for (unit in c("day", "week")) { +# The range the bins must respect: the picker's range when complete, +# otherwise the dated answers' own extent. +timeline_bounds <- function(window, dates) { + if (length(window) >= 2) { + return(as.Date(c(window[[1]], window[[2]]))) + } + if (length(dates) == 0) { + return(NULL) + } + c(min(dates), max(dates)) +} + +# The finest unit whose bins hold `target` answers on average -- +# multiplication rather than mean() so zero dates stay on the day unit +# instead of dividing by zero. Units the window doesn't span at least a +# couple of times over aren't considered at all: a one-day selection +# charts that day however few answers it holds, never its whole month. +# When nothing reaches the target, the coarsest unit still in play. +timeline_bin_unit <- function(dates, bounds, target) { + if (is.null(bounds)) { + return("day") + } + span <- as.integer(bounds[[2]] - bounds[[1]]) + 1L + units <- c("day", if (span >= 14) "week", if (span >= 60) "month") + for (unit in units) { bins <- unique(timeline_bin_start(dates, unit)) if (length(dates) >= target * length(bins)) { return(unit) } } - "month" + units[[length(units)]] } # Weeks start on Monday (ISO), months on the first. @@ -1146,13 +1161,58 @@ timeline_bin_start <- function(dates, unit) { ) } -timeline_bin_label <- function(start, unit) { - switch( - unit, - day = format(start, "%b %e, %Y"), - week = paste("Week of", format(start, "%b %e, %Y")), - month = format(start, "%B %Y") - ) +# Bin labels never reach outside the window: a week or month the window +# covers only part of labels itself by the days it actually holds +# ("Jul 2-5, 2026"), and only a fully covered month wears its plain name. +timeline_bin_label <- function(start, unit, bounds) { + if (identical(unit, "day")) { + return(format(start, "%b %e, %Y")) + } + end <- if (identical(unit, "week")) { + start + 6 + } else { + seq(start, by = "1 month", length.out = 2)[[2]] - 1 + } + from <- max(start, bounds[[1]]) + to <- min(end, bounds[[2]]) + if (identical(unit, "month") && from == start && to == end) { + return(format(start, "%B %Y")) + } + timeline_range_label(from, to) +} + +timeline_range_label <- function(from, to) { + day <- function(date) sub("^\\s+", "", format(date, "%e")) + if (from == to) { + format(from, "%b %e, %Y") + } else if (identical(format(from, "%Y-%m"), format(to, "%Y-%m"))) { + sprintf( + "%s %s\u2013%s, %s", + format(from, "%b"), + day(from), + day(to), + format(from, "%Y") + ) + } else if (identical(format(from, "%Y"), format(to, "%Y"))) { + sprintf( + "%s %s \u2013 %s %s, %s", + format(from, "%b"), + day(from), + format(to, "%b"), + day(to), + format(from, "%Y") + ) + } else { + sprintf( + "%s %s, %s \u2013 %s %s, %s", + format(from, "%b"), + day(from), + format(from, "%Y"), + format(to, "%b"), + day(to), + format(to, "%Y") + ) + } } # The table is the same data as the chart, readable without a pointer, and @@ -1181,32 +1241,45 @@ trust_timeline <- function(binned) { # A 100%-stacked area chart of each bin's trust-level shares -- one stacked # column when only one bin is dated, since a single point can't make an # area. The tooltip is one card drawn wholly from per-bin text: unified -# hover's date header can't carry the bin's n beside the date, so the top -# trace renders header and swatch rows itself and the traces beneath skip -# hover. The card header's legend names the levels, so the widget's own -# legend stays off. +# hover's date header can't carry the bin's n beside the date, so a single +# carrier trace renders header and swatch rows itself and the other traces +# skip hover. The card header's legend names the levels, so the widget's +# own legend stays off. timeline_plot <- function(bins, unit) { dates <- as.Date(vapply(bins, function(bin) bin$date, character(1))) n <- vapply(bins, function(bin) bin$n, numeric(1)) tooltips <- vapply(bins, timeline_tooltip, character(1)) plot <- plotly::plot_ly(height = 176) + # The area chart's card rides the top trace, whose cumulative y is always + # 100; a zero-height bar segment never fires hover, so the lone column's + # card rides its tallest segment instead. + carrier <- if (length(bins) == 1) { + which.max(vapply( + names(viewer_levels), + function(key) bins[[1]]$counts[[key]], + numeric(1) + )) + } else { + length(viewer_levels) + } + for (k in seq_along(viewer_levels)) { key <- names(viewer_levels)[[k]] counts <- vapply(bins, function(bin) bin$counts[[key]], numeric(1)) shares <- 100 * counts / n - top <- k == length(viewer_levels) + carries <- k == carrier plot <- if (length(bins) == 1) { plotly::add_bars( plot, x = dates, y = shares, name = unname(viewer_levels[[key]]), - text = if (top) tooltips, + text = if (carries) tooltips, # Bars would print their text on the bar itself. textposition = "none", - hovertemplate = if (top) "%{text}", - hoverinfo = if (!top) "skip", + hovertemplate = if (carries) "%{text}", + hoverinfo = if (!carries) "skip", marker = list(color = viewer_level_colors[[key]]), # About two hours wide, in the date axis's milliseconds; with the # axis pinned a day either side, a column rather than a fill. @@ -1218,9 +1291,9 @@ timeline_plot <- function(bins, unit) { x = dates, y = shares, name = unname(viewer_levels[[key]]), - text = if (top) tooltips, - hovertemplate = if (top) "%{text}", - hoverinfo = if (!top) "skip", + text = if (carries) tooltips, + hovertemplate = if (carries) "%{text}", + hoverinfo = if (!carries) "skip", type = "scatter", mode = "lines", stackgroup = "levels", @@ -1230,7 +1303,7 @@ timeline_plot <- function(bins, unit) { # edge and draws nothing. line = list( color = "#ffffff", - width = if (top) 0 else 2 + width = if (k == length(viewer_levels)) 0 else 2 ) ) } diff --git a/tests/testthat/test-view-trajectories.R b/tests/testthat/test-view-trajectories.R index ed65803..3e842b5 100644 --- a/tests/testthat/test-view-trajectories.R +++ b/tests/testthat/test-view-trajectories.R @@ -448,24 +448,29 @@ test_that("trust_timeline_bins aggregates question tags by day at volume", { test_that("trust_timeline_bins widens bins until they hold enough answers", { # Two answers a day averages under five per day but over five per - # Monday-start week; Thursday July 2, 2026 through Wednesday July 8 - # spans the weeks of Monday June 29 and Monday July 6. + # Monday-start week; Thursday July 2, 2026 through Friday July 17 spans + # three weeks, entering the first midway. weekly <- unlist( - lapply(as.character(seq(as.Date("2026-07-02"), by = 1, length.out = 7)), { + lapply(as.character(seq(as.Date("2026-07-02"), by = 1, length.out = 16)), { function(date) lapply(c("A", "C"), timeline_question, date) }), recursive = FALSE ) binned <- trust_timeline_bins(weekly) expect_equal(binned$unit, "week") - expect_length(binned$bins, 2) - expect_equal(binned$bins[[1]]$date, "2026-06-29") - expect_equal(binned$bins[[1]]$label, "Week of Jun 29, 2026") + expect_length(binned$bins, 3) + # A bin the range enters midway charts and labels itself by the days the + # range actually covers, not its full calendar week. + expect_equal(binned$bins[[1]]$date, "2026-07-02") + expect_equal(binned$bins[[1]]$label, "Jul 2\u20135, 2026") expect_equal(binned$bins[[1]]$n, 8) + expect_equal(binned$bins[[2]]$label, "Jul 6\u201312, 2026") + expect_equal(binned$bins[[2]]$n, 14) - # One answer a week doesn't fill weeks either, so bins become months. + # One answer a week doesn't fill weeks either, so bins become months; + # only fully covered months wear their plain names. monthly <- lapply( - as.character(seq(as.Date("2026-06-01"), by = 7, length.out = 9)), + as.character(seq(as.Date("2026-06-01"), by = 7, length.out = 10)), timeline_question, tag = "A" ) @@ -473,13 +478,34 @@ test_that("trust_timeline_bins widens bins until they hold enough answers", { expect_equal(binned$unit, "month") expect_equal( vapply(binned$bins, function(bin) bin$label, character(1)), - c("June 2026", "July 2026") + c("June 2026", "July 2026", "Aug 1\u20133, 2026") ) expect_equal(trust_timeline_bins(list())$unit, "day") expect_length(trust_timeline_bins(list())$bins, 0) }) +test_that("bins never grow coarser than the selected window", { + # A one-day window charts that day however few answers it holds. + sparse <- lapply(c("A", "C"), timeline_question, "2026-07-01") + window <- as.Date(c("2026-07-01", "2026-07-01")) + binned <- trust_timeline_bins(sparse, window) + expect_equal(binned$unit, "day") + expect_equal(binned$bins[[1]]$label, "Jul 1, 2026") + + # Without a window the answers' own one-day extent pins the unit too. + expect_equal(trust_timeline_bins(sparse)$unit, "day") + + # A week-long window never bins by month, even when days run sparse. + week_window <- as.Date(c("2026-07-01", "2026-07-07")) + spread <- lapply( + c("2026-07-01", "2026-07-03", "2026-07-06"), + timeline_question, + tag = "A" + ) + expect_equal(trust_timeline_bins(spread, week_window)$unit, "day") +}) + test_that("trust_timeline renders a chart and its table view", { skip_if_not_installed("htmltools") skip_if_not_installed("plotly") From cdd3beadcf230895b511e077e4e4feaaf01f9848 Mon Sep 17 00:00:00 2001 From: skaltman Date: Mon, 3 Aug 2026 13:21:45 -0700 Subject: [PATCH 16/38] Add keyboard shortcut to save note with Enter key --- inst/www/commons-viewer/commons-viewer.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/inst/www/commons-viewer/commons-viewer.js b/inst/www/commons-viewer/commons-viewer.js index e56f7c2..e1e8b8c 100644 --- a/inst/www/commons-viewer/commons-viewer.js +++ b/inst/www/commons-viewer/commons-viewer.js @@ -103,6 +103,23 @@ activateExchange(node); }); + // Enter saves the note like the chat box sends a message; Shift+Enter + // still inserts a newline. The value rides along explicitly because + // Shiny's own textarea updates are debounced and could arrive after + // the click. + document.addEventListener("keydown", function(event) { + if (event.key !== "Enter" || event.shiftKey || event.isComposing) { + return; + } + var target = event.target; + if (!target || target.id !== "review_note") return; + event.preventDefault(); + var button = document.getElementById("save_note"); + if (!button) return; + Shiny.setInputValue("review_note", target.value); + button.click(); + }); + // The divider between the transcript and the notes pane drags (and, for // keyboard users, arrows) the pane's width, within bounds that keep both // panes usable. From 8d4371ae6b37836bd109f8682e7ff14548f8c99d Mon Sep 17 00:00:00 2001 From: skaltman Date: Mon, 3 Aug 2026 13:23:36 -0700 Subject: [PATCH 17/38] Add tooltip to flag button --- R/view-trajectories.R | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/R/view-trajectories.R b/R/view-trajectories.R index b82f7f1..fb011c9 100644 --- a/R/view-trajectories.R +++ b/R/view-trajectories.R @@ -873,17 +873,19 @@ flag_button <- function(flagged, whole_conversation) { } else { sprintf("Flag this %s for review", what) } - shiny::actionButton( - "flag_toggle", - label = "\u2691", - class = if (flagged) { - "commons-viewer-flag-button commons-viewer-flag-button-on" - } else { - "commons-viewer-flag-button" - }, - title = title, - `aria-label` = title, - `aria-pressed` = if (flagged) "true" else "false" + bslib::tooltip( + shiny::actionButton( + "flag_toggle", + label = "\u2691", + class = if (flagged) { + "commons-viewer-flag-button commons-viewer-flag-button-on" + } else { + "commons-viewer-flag-button" + }, + `aria-label` = title, + `aria-pressed` = if (flagged) "true" else "false" + ), + title ) } From abf4a52415c2a55d80b80e35eaa80c228e78dcc3 Mon Sep 17 00:00:00 2001 From: skaltman Date: Mon, 3 Aug 2026 13:38:58 -0700 Subject: [PATCH 18/38] Fix stylesheet load order and hover behavior in timeline chart --- R/view-trajectories.R | 67 +++++++++++----------- inst/www/commons-viewer/commons-viewer.css | 16 ++---- tests/testthat/test-view-trajectories.R | 34 ++++++++--- 3 files changed, 66 insertions(+), 51 deletions(-) diff --git a/R/view-trajectories.R b/R/view-trajectories.R index fb011c9..d0e4188 100644 --- a/R/view-trajectories.R +++ b/R/view-trajectories.R @@ -447,7 +447,15 @@ viewer_ui <- function(summary) { ) ) ), - list(commons_chat_dependency(), commons_viewer_dependency()) + c( + # In a live commons app, commons-chat.css loads after shinychat's + # stylesheet and wins its specificity ties -- the quiet tool rows, + # among others. Here the chat is dynamically rendered, which would + # deliver shinychat's sheet last; pinning it into the head restores + # the live app's order, so transcripts wear the same styling. + htmltools::findDependencies(shinychat::chat_ui("commons_viewer_probe")), + list(commons_chat_dependency(), commons_viewer_dependency()) + ) ) } @@ -978,12 +986,18 @@ conversation_meta <- function(record) { sprintf("%s \u00b7 %s", turns, date) } +# Entries stamp themselves with the local time as well as the date, so +# same-day conversations stay tellable apart in the list. entry_date <- function(record) { - date <- local_date(record$last_active) - if (is.na(date)) { + time <- record$last_active + if (is.na(time)) { return(NULL) } - format(date, "%b %e, %Y") + sprintf( + "%s %s", + format(time, "%b %e, %Y"), + sub("^0", "", format(time, "%I:%M %p")) + ) } viewer_empty_note <- function(text) { @@ -1242,46 +1256,32 @@ trust_timeline <- function(binned) { # A 100%-stacked area chart of each bin's trust-level shares -- one stacked # column when only one bin is dated, since a single point can't make an -# area. The tooltip is one card drawn wholly from per-bin text: unified -# hover's date header can't carry the bin's n beside the date, so a single -# carrier trace renders header and swatch rows itself and the other traces -# skip hover. The card header's legend names the levels, so the widget's -# own legend stays off. +# area. The tooltip is one card drawn wholly from per-bin text -- unified +# hover's date header can't carry the bin's n beside the date -- and every +# trace carries the same card: with closest-point hover, the label then +# anchors to whichever band boundary sits nearest the pointer instead of +# always to the chart's top edge. The card header's legend names the +# levels, so the widget's own legend stays off. timeline_plot <- function(bins, unit) { dates <- as.Date(vapply(bins, function(bin) bin$date, character(1))) n <- vapply(bins, function(bin) bin$n, numeric(1)) tooltips <- vapply(bins, timeline_tooltip, character(1)) plot <- plotly::plot_ly(height = 176) - # The area chart's card rides the top trace, whose cumulative y is always - # 100; a zero-height bar segment never fires hover, so the lone column's - # card rides its tallest segment instead. - carrier <- if (length(bins) == 1) { - which.max(vapply( - names(viewer_levels), - function(key) bins[[1]]$counts[[key]], - numeric(1) - )) - } else { - length(viewer_levels) - } - for (k in seq_along(viewer_levels)) { key <- names(viewer_levels)[[k]] counts <- vapply(bins, function(bin) bin$counts[[key]], numeric(1)) shares <- 100 * counts / n - carries <- k == carrier plot <- if (length(bins) == 1) { plotly::add_bars( plot, x = dates, y = shares, name = unname(viewer_levels[[key]]), - text = if (carries) tooltips, + text = tooltips, # Bars would print their text on the bar itself. textposition = "none", - hovertemplate = if (carries) "%{text}", - hoverinfo = if (!carries) "skip", + hovertemplate = "%{text}", marker = list(color = viewer_level_colors[[key]]), # About two hours wide, in the date axis's milliseconds; with the # axis pinned a day either side, a column rather than a fill. @@ -1293,9 +1293,11 @@ timeline_plot <- function(bins, unit) { x = dates, y = shares, name = unname(viewer_levels[[key]]), - text = if (carries) tooltips, - hovertemplate = if (carries) "%{text}", - hoverinfo = if (!carries) "skip", + text = tooltips, + hovertemplate = "%{text}", + # Anchor to the boundary lines' points; fills would hover at a + # polygon centroid instead. + hoveron = "points", type = "scatter", mode = "lines", stackgroup = "levels", @@ -1324,9 +1326,10 @@ timeline_plot <- function(bins, unit) { plot <- plotly::layout( plot, barmode = "stack", - # Hover snaps to the nearest bin's x with no pixel cutoff, so the one - # text-bearing trace fires anywhere in the fills, as unified hover did. - hovermode = "x", + # Closest-point hover with no pixel cutoff: the card fires anywhere in + # the fills and anchors to the nearest band boundary at the nearest + # bin, pointing at the band under the cursor rather than the top edge. + hovermode = "closest", hoverdistance = -1, hoverlabel = list( align = "left", diff --git a/inst/www/commons-viewer/commons-viewer.css b/inst/www/commons-viewer/commons-viewer.css index 04a9c62..8c8dabd 100644 --- a/inst/www/commons-viewer/commons-viewer.css +++ b/inst/www/commons-viewer/commons-viewer.css @@ -1,6 +1,7 @@ -/* shinychat's stylesheet arrives with the dynamically rendered chat, after - * this one, so overrides of its rules carry an extra class of specificity - * to win regardless of load order. */ +/* viewer_ui() pins shinychat's stylesheet into the head ahead of + * commons-chat.css and this sheet, so transcripts wear the same styling a + * live commons app shows. Overrides of shinychat rules still carry an + * extra class of specificity, winning even if the order regresses. */ /* shinychat falls back to a robot icon on assistant messages when no * icon_assistant is configured; commons chats carry no icon. */ @@ -13,15 +14,6 @@ display: none; } -/* In a live commons app, commons-chat.css loads after shinychat's sheet, so - * its gray user bubble wins; here the order flips (shinychat's sheet rides - * in with the dynamically rendered chat), so restate the variables from - * commons-chat.css's shiny-chat-container block. */ -.commons-viewer-transcript shiny-chat-container { - --shiny-chat-user-message-bg: var(--bs-tertiary-bg, #f4f5f7); - --shiny-tool-card-spinner-color: var(--bs-secondary-color, #6c757d); -} - /* ---- Timeline ----------------------------------------------------------- */ .commons-viewer-timeline-card { diff --git a/tests/testthat/test-view-trajectories.R b/tests/testthat/test-view-trajectories.R index 3e842b5..02f54cd 100644 --- a/tests/testthat/test-view-trajectories.R +++ b/tests/testthat/test-view-trajectories.R @@ -520,8 +520,9 @@ test_that("trust_timeline renders a chart and its table view", { bins <- binned$bins # One stacked trace per trust level, sharing each bin's answers out as - # percentages; the top trace draws the whole hover card, with the bin's - # n up beside the date, and the others skip hover. + # percentages. Every trace carries the same hover card -- with the bin's + # n up beside the date -- so the card anchors to whichever band boundary + # sits nearest the pointer. traces <- plotly::plotly_build(timeline_plot(bins, binned$unit))$x$data expect_length(traces, length(viewer_levels)) expect_equal( @@ -530,11 +531,11 @@ test_that("trust_timeline renders a chart and its table view", { ) expect_equal(as.numeric(traces[[1]]$y), c(100, 60)) expect_equal(as.numeric(traces[[3]]$y), c(0, 40)) - top <- traces[[length(traces)]] - expect_true(all(top$hovertemplate == "%{text}")) - expect_match(top$text[[1]], "Jul 1, 2026 (n = 5)", fixed = TRUE) - expect_match(top$text[[2]], "60% Verified", fixed = TRUE) - expect_true(all(traces[[1]]$hoverinfo == "skip")) + for (trace in traces) { + expect_true(all(trace$hovertemplate == "%{text}")) + expect_match(trace$text[[1]], "Jul 1, 2026 (n = 5)", fixed = TRUE) + expect_match(trace$text[[2]], "60% Verified", fixed = TRUE) + } # A single dated bin charts as a stacked column instead of an area. single <- plotly::plotly_build(timeline_plot(bins[1], binned$unit))$x$data @@ -546,6 +547,25 @@ test_that("trust_timeline renders a chart and its table view", { expect_match(html, "60% (3)", fixed = TRUE) }) +test_that("viewer_ui pins shinychat's styles ahead of commons-chat's", { + skip_if_not_installed("shiny") + skip_if_not_installed("bslib") + skip_if_not_installed("shinychat") + skip_if_not_installed("htmltools") + skip_if_not_installed("shinyWidgets") + + # Ties between the sheets resolve by load order; the dynamically rendered + # chat must not deliver shinychat's sheet after commons-chat.css, or + # transcripts lose the commons look (quiet tool rows, gray user bubbles). + deps <- vapply( + htmltools::findDependencies(viewer_ui(list())), + function(dep) dep$name, + character(1) + ) + expect_lt(match("shinychat", deps), match("commons-chat", deps)) + expect_lt(match("commons-chat", deps), match("commons-viewer", deps)) +}) + test_that("the timeline legend tucks each level's rate into its tooltip", { skip_if_not_installed("htmltools") From 5c89ce51b345486bf5fd197443ca1ae2c9bc37a4 Mon Sep 17 00:00:00 2001 From: skaltman Date: Mon, 3 Aug 2026 14:39:53 -0700 Subject: [PATCH 19/38] Add tool display reconstruction --- R/view-trajectories.R | 54 +++++++++++++++++++++++++ tests/testthat/test-view-trajectories.R | 51 +++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/R/view-trajectories.R b/R/view-trajectories.R index d0e4188..13ea4be 100644 --- a/R/view-trajectories.R +++ b/R/view-trajectories.R @@ -315,12 +315,66 @@ exchange_answer_chunks <- function(turns) { if (S7::S7_inherits(content, ellmer::ContentToolRequest)) { next } + if (S7::S7_inherits(content, ellmer::ContentToolResult)) { + content@extra$display <- content@extra$display %||% + viewer_tool_display(content@request, content@value) + } chunks[[length(chunks) + 1]] <- shinychat::contents_shinychat(content) } } drop_nulls(chunks) } +# Runtime tool results carry display metadata (tool_result()'s +# extra$display: the quiet row's title and icon) that the OTLP round trip +# drops, so reconstructed results would fall back to shinychat's default +# card under the raw function name. Re-derive it from the request's tool +# name and arguments, mirroring the titles in tools.R; runtime-only detail +# (source labels, measure display HTML) is beyond reconstruction. Unknown +# tools keep the default card. +viewer_tool_display <- function(request, value = NULL) { + if (is.null(request)) { + return(NULL) + } + arguments <- request@arguments + info <- switch( + request@name, + search_pool = list(title = "Searched the semantic layer", icon = "search"), + call_metrics = list( + title = sprintf( + "Metrics: %s", + html_escape(paste(unlist(arguments$metrics), collapse = ", ")) + ), + icon = "shield-check" + ), + call_measure = list( + title = sprintf( + "Measure: %s", + html_escape(humanize_name(arguments$name %||% "")) + ), + icon = "shield-check" + ), + search_context = list(title = "Searched context", icon = "book"), + describe_table = list( + title = sprintf("Described %s", html_escape(arguments$table %||% "")), + icon = "table" + ), + run_sql = list(title = "Ran SQL", icon = "code-square"), + run_r = list(title = "Ran R code", icon = "terminal"), + return(NULL) + ) + display <- list(title = info$title, open = FALSE, show_request = FALSE) + display$icon <- maybe_icon(info$icon) + # Expanding a SQL row shows the query above its result, as at runtime. + if (identical(request@name, "run_sql") && is.character(arguments$sql)) { + display$markdown <- paste( + c(sprintf("```sql\n%s\n```", arguments$sql), value), + collapse = "\n\n" + ) + } + display +} + # Replays a conversation into a bound chat element. Assistant messages # stream as chunks -- the mode the pill-seed handler was designed around -- # and the seed is sent last so pills land once the transcript settles. diff --git a/tests/testthat/test-view-trajectories.R b/tests/testthat/test-view-trajectories.R index 02f54cd..9893905 100644 --- a/tests/testthat/test-view-trajectories.R +++ b/tests/testthat/test-view-trajectories.R @@ -197,6 +197,57 @@ test_that("trajectory_transcript merges each exchange into chat messages", { expect_equal(transcript$pills[[2]]$indexFromEnd, 0) }) +test_that("reconstructed tool results wear the commons display again", { + skip_if_not_installed("shinychat") + skip_if_not_installed("htmltools") + + display <- viewer_tool_display( + ellmer::ContentToolRequest( + id = "c1", + name = "run_sql", + arguments = list(sql = "select 1") + ), + value = "| 1 |" + ) + expect_equal(display$title, "Ran SQL") + expect_false(display$show_request) + expect_equal(display$markdown, "```sql\nselect 1\n```\n\n| 1 |") + + measure <- viewer_tool_display(ellmer::ContentToolRequest( + id = "c2", + name = "call_measure", + arguments = list(name = "biodiversity_by_site", arguments = "{}") + )) + expect_equal(measure$title, "Measure: biodiversity by site") + + # Titles interpolate model-supplied arguments, so they are escaped. + described <- viewer_tool_display(ellmer::ContentToolRequest( + id = "c3", + name = "describe_table", + arguments = list(table = "