Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
43c49ee
fix: return JSON for data frame tool results
gadenbuie Sep 7, 2026
7d2d7f8
fix: collapse multi-element character tool result values
gadenbuie Sep 7, 2026
b61b9cd
test: assert tool result values are a single string
gadenbuie Sep 7, 2026
38a11ff
fix: stay compatible with shinychat 0.5.0 tool display protocol
gadenbuie Sep 7, 2026
38832f2
fix: render subagent full conversation with contents_html()
gadenbuie Sep 7, 2026
b4152fb
style: dress up subagent report disclosures
gadenbuie Sep 7, 2026
5ab0191
fix(run): make run-R tool results serializable by shinychat history
gadenbuie Sep 7, 2026
7c81ed1
test: assert tool results survive shinychat history serialization
gadenbuie Sep 7, 2026
6476a3b
feat(app): support shinychat >= 0.5.0 page_chat layout
gadenbuie Sep 7, 2026
226f82a
feat(app): persist chat history in duckdb when available
gadenbuie Sep 7, 2026
ab68d1e
fix(run): don't show request
gadenbuie Sep 7, 2026
04b6db1
feat(app): register slash commands with shinychat >= 0.5.0
gadenbuie Sep 7, 2026
f7ec0e4
feat(skills): export skill prompt and slash command registration helpers
gadenbuie Sep 7, 2026
7fc91a4
feat(app): add /new and /clear slash commands and conversation-aware …
gadenbuie Sep 7, 2026
c2efe72
fix: gate shinychat 0.5 tests and example for CRAN shinychat 0.4.0
gadenbuie Sep 7, 2026
99d7768
docs: gate the chat_server example on shinychat 0.5.0 with examplesIf
gadenbuie Sep 7, 2026
e35219f
chore: require dev shinychat and install it on CI
gadenbuie Sep 7, 2026
05cd8de
fix: call shinychat 0.5.0 APIs through runtime proxies
gadenbuie Sep 7, 2026
e719845
Revert "chore: require dev shinychat and install it on CI"
gadenbuie Sep 7, 2026
a8f5ba1
fix(app): escape untrusted markdown in subagent reports
gadenbuie Sep 8, 2026
d1a8b72
fix(app): wait for streaming responses before running skills
gadenbuie Sep 8, 2026
0fded16
fix(app): escape error messages in notifier toasts
gadenbuie Sep 8, 2026
03266d0
fix(app): stop status counter observer after initialization
gadenbuie Sep 8, 2026
6104eb5
refactor(run): drop unserializable tool results early
gadenbuie Sep 8, 2026
7ab3c6d
fix: delegate app chat clears to shinychat's history-aware clear
gadenbuie Sep 8, 2026
96e0fac
fix: use shinychat's chat$new_chat() for app chat resets
gadenbuie Sep 9, 2026
cb81e10
chore: require shinychat >= 0.5.0
gadenbuie Sep 9, 2026
92842f0
fix: run skill slash command example only inside a Shiny app
gadenbuie Sep 9, 2026
4fcc7c6
docs: show skill slash command usage in description
gadenbuie Sep 9, 2026
15c176f
chore: move shinychat back to suggests
gadenbuie Sep 9, 2026
1a60441
docs: reorganize shinychat v0.5.0 NEWS entries under one bullet
gadenbuie Sep 9, 2026
b7840fd
fix(tools): align tool result display fields with shinychat 0.5.0
gadenbuie Sep 9, 2026
783b198
feat(app): warn to upgrade shinychat when using legacy layout
gadenbuie Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Imports:
brio,
cli,
clipr,
commonmark,
dplyr,
ellmer (>= 0.4.2),
frontmatter,
Expand Down Expand Up @@ -76,12 +77,12 @@ Suggests:
renv,
roxygen2,
shiny,
shinychat (>= 0.4.0),
shinychat (>= 0.5.0),
testthat (>= 3.0.0),
tibble,
usethis
Config/Needs/website: brand.yml, tidyverse/tidytemplate
Config/roxygen2/version: 8.0.0
Config/roxygen2/version: 8.1.0
Config/testthat/edition: 3
Config/testthat/parallel: true
Config/testthat/start-first: web, news, covr, search
Expand All @@ -93,8 +94,13 @@ Collate:
'btw-config.R'
'btw-package.R'
'btw.R'
'btw_chat_history_store.R'
'btw_client.R'
'btw_client_app.R'
'btw_client_app_core.R'
'btw_client_app_legacy.R'
'btw_client_app_slash_commands.R'
'btw_client_app_v05.R'
'btw_task.R'
'btw_this.R'
'cli.R'
Expand Down Expand Up @@ -140,5 +146,8 @@ Collate:
'utils-ide.R'
'utils-md.R'
'utils-r.R'
'utils-shinychat.R'
'utils.R'
'zzz.R'
Remotes:
posit-dev/shinychat@rc-v0.5.0
12 changes: 8 additions & 4 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export(btw_mcp_session)
export(btw_skill_install_github)
export(btw_skill_install_package)
export(btw_skill_install_project)
export(btw_skill_prompt)
export(btw_skills_register_slash_commands)
export(btw_task)
export(btw_task_create_btw_md)
export(btw_task_create_readme)
Expand Down Expand Up @@ -87,8 +89,10 @@ export(install_btw_cli)
export(use_btw_md)
if (getRversion() < "4.3.0") importFrom("S7", "@")
import(rlang)
importFrom(brio,read_file)
importFrom(brio,read_lines)
importFrom(brio,write_file)
importFrom(brio,write_lines)
importFrom(brio,
read_file,
read_lines,
write_file,
write_lines
)
importFrom(lifecycle,deprecated)
18 changes: 18 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@

* `btw docs` and `btw pkg src` commands now auto-load an in-development package found in the current directory (or its `pkg-r/`, `R/` subfolder) with `pkgload::load_all()` before running, so results reflect uncommitted local changes. Pass `--no-dev` to disable this and use the installed package instead (#212).

* btw now supports shinychat >= 0.5.0 (#215):

- `btw_app()` uses shinychat's new `page_chat()` layout, with the tool selection menu and the system prompt editor in offcanvas panels. When shinychat 0.4.x is installed, `btw_app()` falls back to the legacy sidebar layout and warns, suggesting you upgrade to shinychat 0.5.0.

- Conversations are kept in a chat history. Past chats appear in the app's history drawer, so you can revisit a previous conversation at any time. When duckdb is installed, btw stores this history in a shared database in btw's user cache directory, with one history per project, so your chats are still there the next time you open the app in the same project. Without duckdb, conversations are not saved between sessions. btw prints a note that suggests installing duckdb.

- btw's `@` commands are also available as `/btw-*` slash commands, e.g. `/btw-help dplyr::across`, `/btw-news dplyr v1.1.4`, or `/btw-url https://example.com`. A slash command stages its context as an attachment on the chat input and shows a toast while it runs. Nothing is sent to the model until you submit your next message. If a command fails, `btw_app()` restores your input text and shows an error toast.

- Skills are registered as slash commands under their own names, e.g. `/skill-creator`. Running a skill submits its instructions with your message to the model.

- `/new` and `/clear` start a new chat. If chat history is enabled, `btw_app()` first saves the current conversation with shinychat's history-aware `chat$new_chat()` (posit-dev/shinychat#399), so the conversation isn't dropped from the history. The token and cost counters follow the active conversation and restore their totals when you load an old chat from the history.

- Subagent reports use shinychat 0.5.0's HTML display protocol (`contents_html()`), with the full subagent conversation in a styled disclosure. `btw_app()` escapes untrusted markdown in subagent reports and in error toasts.

- Tool results survive shinychat's conversation history serialization, so chats with tool results can be saved and restored. btw collapses multi-element character values to a single string, returns data frame results as JSON, and drops unserializable objects from run-R results.

- Two new functions make the skill features reusable outside `btw_app()`. `btw_skill_prompt()` renders a skill's `<skill>` block, including its name, description, location, and optional compatibility and allowed-tools metadata. `btw_skills_register_slash_commands()` registers skills as slash commands on any shinychat `chat_server()`.

## Bug fixes

* `btw --version` now works without a subcommand. Previously, Rapp's required top-level switch caused the CLI to print usage and exit before the version flag was ever checked.
Expand Down
256 changes: 256 additions & 0 deletions R/btw_chat_history_store.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
# nocov start

# Persistent conversation history for btw_app(). When duckdb is installed,
# shinychat's chat history is stored in a single database in the btw user
# cache directory, keyed by project directory
# (btw_app_history_project_dir()).

btw_chat_history_db_path <- function() {
path_btw_cache("chat_history.duckdb")
}

# Connect to a duckdb database, retrying while the file is locked by another
# process (duckdb allows only one writer process at a time). The connection
# and its driver are closed when the calling frame exits, even on error.
btw_duckdb_connect <- function(
path,
timeout = 10,
wait = 0.5,
max_delay = 2,
.envir = parent.frame()
) {
fs::dir_create(fs::path_dir(path))

deadline <- Sys.time() + timeout
delay <- wait

repeat {
drv <- NULL
connected <- tryCatch(
{
drv <- duckdb::duckdb(dbdir = path)
list(con = DBI::dbConnect(drv), drv = drv)
},
error = function(err) {
if (!is.null(drv)) {
duckdb::duckdb_shutdown(drv)
}
err
}
)

if (!inherits(connected, "error")) {
break
}

if (Sys.time() >= deadline) {
cli::cli_abort(c(
"Could not connect to {.path {path}} within {timeout} seconds.",
"i" = "The database may be locked by another process."
))
}

Sys.sleep(delay)
delay <- min(delay * 2, max_delay)
}

withr::defer(
{
DBI::dbDisconnect(connected$con)
duckdb::duckdb_shutdown(connected$drv)
},
envir = .envir
)

connected$con
}

btw_duckdb_init <- function(con) {
DBI::dbExecute(
con,
"CREATE TABLE IF NOT EXISTS conversations (
scope VARCHAR NOT NULL,
chat_id VARCHAR NOT NULL,
id VARCHAR NOT NULL,
title VARCHAR,
created_at VARCHAR,
updated_at VARCHAR,
size_bytes DOUBLE,
data VARCHAR,
PRIMARY KEY (scope, chat_id, id)
)"
)
invisible(NULL)
}

btw_conversation_store_duckdb <- R6::R6Class(
"btw_conversation_store_duckdb",
inherit = shinychat_conversation_store(),
private = list(
db_path = NULL,

# Run `fn(con)` on a fresh connection to the store database, degrading
# gracefully (warn + `fallback`) if the database can't be reached.
with_db = function(op, fallback, fn) {
tryCatch(
{
con <- btw_duckdb_connect(private$db_path)
btw_duckdb_init(con)
fn(con)
},
error = function(err) {
cli::cli_warn(c(
"Failed to {.emph {op}} chat history in {.path {private$db_path}}.",
conditionMessage(err)
))
fallback
}
)
}
),
public = list(
initialize = function(db_path = NULL) {
private$db_path <- db_path %||% btw_chat_history_db_path()
},

list = function(partition) {
private$with_db(
"list",
list(),
function(con) {
rows <- DBI::dbGetQuery(
con,
"SELECT id, title, created_at, updated_at, size_bytes
FROM conversations
WHERE scope = ? AND chat_id = ?
ORDER BY created_at DESC",
params = list(partition$scope, partition$chat_id)
)
if (nrow(rows) == 0) {
return(list())
}
lapply(seq_len(nrow(rows)), function(i) {
list(
id = rows$id[[i]],
title = rows$title[[i]],
created_at = rows$created_at[[i]],
updated_at = rows$updated_at[[i]],
size_bytes = rows$size_bytes[[i]]
)
})
}
)
},

get = function(partition, id) {
private$with_db(
"read",
NULL,
function(con) {
rows <- DBI::dbGetQuery(
con,
"SELECT data FROM conversations
WHERE scope = ? AND chat_id = ? AND id = ?",
params = list(partition$scope, partition$chat_id, id)
)
if (nrow(rows) == 0) {
return(NULL)
}
jsonlite::unserializeJSON(rows$data[[1]])
}
)
},

put = function(partition, record) {
data <- jsonlite::serializeJSON(record, digits = 17)
size_bytes <- as.double(nchar(data, type = "bytes"))

private$with_db(
"save",
invisible(NULL),
function(con) {
DBI::dbExecute(
con,
"INSERT INTO conversations
(scope, chat_id, id, title, created_at, updated_at, size_bytes, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (scope, chat_id, id) DO UPDATE SET
title = excluded.title,
created_at = excluded.created_at,
updated_at = excluded.updated_at,
size_bytes = excluded.size_bytes,
data = excluded.data",
params = list(
partition$scope,
partition$chat_id,
record$id,
record$title,
record$created_at,
record$updated_at,
size_bytes,
data
)
)
invisible(NULL)
}
)
},

delete = function(partition, id) {
private$with_db(
"delete",
invisible(NULL),
function(con) {
DBI::dbExecute(
con,
"DELETE FROM conversations
WHERE scope = ? AND chat_id = ? AND id = ?",
params = list(partition$scope, partition$chat_id, id)
)
invisible(NULL)
}
)
}
)
)

btw_app_history_project_dir <- function(path_btw = NULL) {
if (!is.null(path_btw)) {
path <- fs::path_abs(fs::path_expand(path_btw))
if (fs::dir_exists(path)) {
return(as.character(fs::path_norm(path)))
}
return(as.character(fs::path_norm(fs::path_dir(path))))
}

marker <-
path_find_in_project("DESCRIPTION") %||%
path_find_in_project(".git")

if (is.null(marker)) {
return(normalizePath(getwd(), winslash = "/"))
}

as.character(fs::path_norm(fs::path_dir(marker)))
}

btw_app_history_options <- function(path_btw = NULL) {
if (!rlang::is_installed("duckdb")) {
cli::cli_inform(
c(
"Chat history: conversations aren't saved between {.fn btw_app} sessions.",
"i" = "Install the {.pkg duckdb} R package to keep your conversation history in a local database: {.code install.packages(\"duckdb\")}."
),
.frequency = "once",
.frequency_id = "btw_app_history_duckdb"
)
return(TRUE)
}

shinychat_history_options(
store = btw_conversation_store_duckdb$new(),
scope = btw_app_history_project_dir(path_btw)
)
}

# nocov end
Loading