Skip to content

Add btw-discoverable shiny-for-r agent skill - #4427

Merged
schloerke merged 55 commits into
mainfrom
schloerke/port-py-shiny-skills-to-btw
Aug 26, 2026
Merged

schloerke merged 55 commits into
mainfrom
schloerke/port-py-shiny-skills-to-btw

Conversation

@schloerke

Copy link
Copy Markdown
Collaborator

Ships an Agent Skill in the shiny package, discoverable by {btw}, porting the py-shiny agent skill to Shiny for R.

What's included

  • inst/skills/shiny-for-r/SKILL.md — router/index skill (frontmatter per btw's validate_skill() contract) with grouped topic tables routing to 21 reference files.
  • inst/skills/shiny-for-r/references/*.md — 21 references (400–800 words each): reactivity, modules, session-lifecycle, async, extended-tasks, layouts, navigation, dashboard-components, dynamic-ui, theming-assets, plots, tables, files, feedback, bookmarking, custom-components, testing, debugging, opentelemetry, dashboard-design, ecosystem. bslib/thematic/brand.yml content is adopted in depth (folded in from the MIT-licensed posit-dev/skills shiny category); other companion packages ({DT}, {plotly}, {leaflet}, {shinychat}, {shinytest2}, ...) stay pointer-level via ecosystem.md — each should ship its own skill in lockstep with its features.
  • tests/testthat/test-skills.R — guard tests: frontmatter validity, SKILL.md↔references link parity, every R chunk parses, and an API-sync list so renaming a documented shiny export fails loudly.
  • README "Agent Skill" section and NEWS entry.

Delivery mechanism (passive, mirai-style)

No R code ships for delivery. btw auto-discovers skills from attached packages via system.file("skills", package = pkg); users can copy the skill into a project with btw::btw_skill_install_package("shiny") or the btw skills CLI. Same convention {mirai} uses (inst/skills/mirai/).

Verification

  • All documented APIs verified against NAMESPACE/man pages; bslib functions against installed bslib 0.12.0 (version caveats included where features postdate shiny's bslib >= 0.6.0 floor).
  • Every full example app is parse-checked in tests; many were executed via testServer() during authoring.
  • End-to-end btw discovery proven: shiny installed to a temp library, library(shiny) + btw::btw_tool_skill("shiny-for-r") returns the skill and lists all 21 reference paths.
  • Full test suite: 0 failures (3234 passing incl. 104 new guard tests).

Follow-ups (separate)

  • File issues on companion packages (DT, reactable, plotly, leaflet, shinychat, shinytest2, reactlog, thematic, promises) asking each to ship its own btw-discoverable skill; drafts prepared.
  • Optionally register shiny-for-r in the posit-dev/skills marketplace (as mirai does, alongside in-package delivery).

schloerke and others added 30 commits August 19, 2026 16:35
Fix four small inaccuracies found in final review of the shiny-for-r
skill: trim the session-lifecycle router entry to what the reference
actually covers and align it on onSessionEnded(); introduce
bsicons::bs_icon() on first use in dashboard-components.md; clarify
that showNotification()/showModal()/Progress$new() need an active
session rather than an observer, in feedback.md; and fix a partial
snippet marker in navigation.md that omitted the server code it
also contains.
observeEvent()'s handlerExpr already runs inside an isolate() scope, so
isolate(input$seed) in that example did nothing. Show the pattern in
observe() instead, and call out that observeEvent()/eventReactive()/
bindEvent() bodies are already isolated.
R/input-submit.R discourages submitButton() in favor of actionButton():
it stops every input in the app from auto-sending, misbehaves with two
submit buttons, and does not work at all when created by renderUI() or
insertUI(). Recommend debounce()/throttle()/eventReactive() instead.
reactlogShow(time = TRUE) defaults to the log shiny just recorded, while
reactlog_show(log, ...) requires the caller to pass one.
Input bindings do not namespace ids: getId() reads data-input-id or the
element's id as-is (srcts/src/bindings/input/inputBinding.ts). What
find(scope) actually provides is re-binding of elements inserted after
page load. Note that module callers must pass ns() themselves.
el.replaceWith(el.cloneNode(true)) swaps the DOM node out from under
Shiny, which still holds a reference to the original element for
unbind/rebind. Use a namespaced jQuery handler removed in unsubscribe().
toolbar(), toolbar_input_button(), toolbar_input_select(),
toolbar_divider(), and the update_toolbar_input_*() helpers all shipped
in bslib 0.11.0 (bslib NEWS, #1247-#1266), not 0.12.0.
The 'last element wins' behavior applies only when trigger renders as
multiple top-level elements. A single span() is entirely hoverable --
bslib's ?tooltip recommends wrapping in div()/span() for exactly that
reason -- so the previous example illustrated the opposite of the rule.
bs_theme(brand = TRUE) errors with 'The package "brand.yml" is
required.' unless that Suggests-level package is installed, so the
snippet was not runnable as written. Also document that discovery
searches _brand/ and brand/ subdirectories and that brand accepts a
list.
fluidPage(..., title, theme, lang) accepts a bs_theme() object, as do
navbarPage() and fillPage(). The real bslib page-function advantages are
sidebar support, fill behavior, and theme = bs_theme() by default.
R/react.R:212 raises 'Operation not allowed without an active reactive
context.'; references/debugging.md already quoted it correctly, so the
two files disagreed.
R/bootstrap.R:1140-1177 forwards renderDataTable()/dataTableOutput() to
DT::renderDT()/DT::DTOutput() when DT >= 0.32.1 is installed; the bundled
legacy DataTables path only runs without DT or under
options(shiny.legacy.datatable = TRUE).
cache = "app" is a cachem::cache_mem() LRU with a default ~200 MB limit,
so entries are evicted; 'remembers every value for a key' overstated it.
R/otel-label.R builds span names from the kind (reactive/observe/output,
plus cache/event modifiers) and the object's label. debounce, throttle,
reactivePoll, and reactiveFileReader contribute to the label, so those
spans read 'reactive reactivePoll <label>' rather than 'reactivePoll'.
Several signatures implied positional arguments that do not exist in that
position: nearPoints()/brushedPoints() omitted panelvar1/panelvar2 before
threshold and allRows, page_sidebar()/input_dark_mode() start with ...,
bs_theme()'s first argument is version, showNotification() takes action
before duration, and renderCachedPlot() has sizePolicy and res before
cache. The prose examples already named these correctly.
expect_no_error()'s `message` argument filters which conditions count as
a failure rather than labeling one, so the sprintf() location string
never matched: the parse error escaped the expectation, aborted the whole
test_that() as an uncaught error, and discarded the file/line label.
Catch the error and pass the location through `info` instead, which also
lets the loop report every bad chunk instead of stopping at the first.
(s + 1):(e - 1) counts backwards when a chunk has no body, so the test
would parse the fence lines themselves rather than the (empty) chunk.
expect_length(missing, 0) already recorded a failure, so the follow-up
fail() reported the same problem twice. Keep only fail(), which names the
missing exports, paired with succeed() so each reference still
contributes one expectation.
A reference file whose name contained a digit or underscore matched
list.files() but not the link pattern, so it would be reported as an
orphaned file rather than as a linked one.
async.md, debugging.md, dashboard-components.md, and dashboard-design.md
had no entry, so the exports they name were unguarded. Add entries for
them and assert that names(apis) matches references/ exactly, so a new
reference cannot pass this test by being absent from the list.
Since the button starts disabled and enables only once the matching
downloadHandler registers, a mismatched output id shows up as a
permanently greyed-out button rather than a dead click.
R/server-input-handlers.R:55-60 stops with 'There is already an input
handler for type: ...' unless force = TRUE, which matters given the
advice to register from .onLoad().
tabsetPanel(), navbarPage(), and navlistPanel() carry no deprecation
badge in man/ and remain fully supported, so 'legacy' overstated their
status. Say superseded and point at the bslib replacement for each.
A three-card grid is one fluidRow() containing three column()s, not
'three nested fluidRow(column(4, ...)) calls'.
The overview now says the bslib functions supersede the shiny ones; make
the section headings and migration table agree.
'Do NOT ... do NOT' told the reader what to avoid without the cost that
makes it worth avoiding; state what each habit actually does to the
reactive graph.
Theming, Extending, and Ecosystem each held one row. Merge them into
neighbouring sections, tell the reader to open only the references their
task touches, and say plainly which areas (plain inputs, deployment,
performance tuning) the skill does not cover.
The reference described mirai and future interchangeably but demonstrated
neither, showing a later::later() promise instead. Use mirai in the
runnable example, mirroring ?ExtendedTask, and cover the two things that
actually bite: daemons() setup and passing values into the background
process explicitly.
Both files listed mirai and future as interchangeable alternatives joined
by 'or'. Name mirai as the concrete way to move work off the main R
process, and split the ecosystem row so {promises} covers composition
while future appears as the promise-coercion route it provides.
Comment thread inst/skills/shiny-for-r/references/ecosystem.md Outdated
Comment thread inst/skills/shiny-for-r/references/ecosystem.md Outdated
Comment thread inst/skills/shiny-for-r/references/extended-tasks.md Outdated
Comment thread inst/skills/shiny-for-r/references/extended-tasks.md Outdated
{yaml} is a Suggests, so the check-depends-only job installs shiny
without it and test-skills.R errored with 'there is no package called
yaml' instead of skipping. This failure predates the current review
fixes; it arrived with the test file itself.
then() takes the promise first, so it pipes with base R and needs no
special operator. Keep a one-line pointer that %...>% appears in older
code so it stays recognizable when reading, without recommending it.
A bare future::future() blocks the main R process once every worker is
busy; future_promise() returns a promise immediately and starts the work
when a worker frees up. Recommend it wherever future is mentioned.
Name promises::future_promise() alongside a plain promise and a
future::future() object, and describe the quick-reference entry as any
promise-like object rather than any promise.
- feedback: state the showNotification/showModal no-session mistake directly
- files: oversized uploads are rejected with an error, not silent
- extended-tasks: drop the ambiguous 'read both naively' sentence
- navigation: navset_underline is a style choice, not a default
- layouts: fluidPage defaults to Bootstrap 3 styling, not 'unthemed'
- session-lifecycle: distinguish fatal (shiny.error.fatal, closes the
  session) from render errors that only show as 'Error' output
- dashboard-components: collapse stray double blank line
@schloerke
schloerke marked this pull request as ready for review August 26, 2026 17:08
@schloerke
schloerke merged commit 8184460 into main Aug 26, 2026
12 checks passed
@schloerke
schloerke deleted the schloerke/port-py-shiny-skills-to-btw branch August 26, 2026 17:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant