Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5717f78
feat: add framed tool result presentation to Python
cpsievert Aug 20, 2026
b5a5aea
test: cover default tool presentation normalization
cpsievert Aug 20, 2026
6ab0ab2
feat: add framed tool result presentation to R
cpsievert Aug 20, 2026
1436519
feat: retain framed tool result presentation in chat
cpsievert Aug 20, 2026
0fd3be0
feat: frame expanded rich tool results
cpsievert Aug 20, 2026
bbae995
fix: keep framed tool focus visible
cpsievert Aug 20, 2026
c989c35
test: cover framed tool result presentation
cpsievert Aug 20, 2026
73392f3
test: strengthen framed result browser coverage
cpsievert Aug 20, 2026
1dbd5ed
docs: describe framed tool result presentation
cpsievert Aug 20, 2026
8fe9219
test: fix framed tool wire protocol typing
cpsievert Aug 20, 2026
78e32da
docs: regenerate tool result display help
cpsievert Aug 20, 2026
9040a85
build: refresh distributed asset revisions
cpsievert Aug 20, 2026
6e4f73e
docs: add framed tool result screenshots
cpsievert Aug 21, 2026
948ef7c
docs: clarify framed tool result states
cpsievert Aug 21, 2026
abd11cc
docs: replace framed result screenshots
cpsievert Aug 21, 2026
97f38d7
docs: add QueryChat framed visualization example
cpsievert Aug 21, 2026
0f8e164
docs: use live QueryChat visualization comparison
cpsievert Aug 21, 2026
a56355e
rm images added by mistake
cpsievert Aug 21, 2026
d6eb0a4
refactor: rename tool result presentation to open_style
cpsievert Aug 22, 2026
64daae9
update test
cpsievert Aug 22, 2026
9a66b77
fix vignette
cpsievert Aug 22, 2026
72b3733
fix: anchor grouped tool rows when framing, add group drill-in rule
cpsievert Aug 25, 2026
ae0e4d1
Merge remote-tracking branch 'origin/main' into feat/framed-tool-resu…
cpsievert Aug 25, 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
2 changes: 1 addition & 1 deletion js/dist/shinychat.css

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions js/dist/shinychat.css.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions js/dist/shinychat.js

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions js/dist/shinychat.js.map

Large diffs are not rendered by default.

13 changes: 11 additions & 2 deletions js/src/chat/ToolGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ function ToolCallRow({
heterogeneous: boolean
}): ReactNode {
const [open, setOpen] = useExpandable(item.expanded)
const framed =
open && item.status === "success" && item.openStyle === "framed"
const label = toolCallLabel(item, segmentTitle, false)
const statusClass =
item.status === "error"
Expand All @@ -203,7 +205,12 @@ function ToolCallRow({
const contentId = `tool-call${useId()}`

return (
<li className="shiny-chat-tool-call-row" role="listitem">
<li
className={`shiny-chat-tool-call-row${
framed ? " shiny-chat-tool-call-row--framed" : ""
}`}
role="listitem"
>
<button
type="button"
className="shiny-chat-tool-call-row__summary"
Expand Down Expand Up @@ -325,14 +332,16 @@ function ToolGroupRow({
bodyId: string
}): ReactNode {
const { identity, single, anyRunning, failedCount, heterogeneous } = row
const framed =
expanded && single?.status === "success" && single.openStyle === "framed"
const label = single && toolCallLabel(single, identity.title, true)
const glyph = glyphHtml(toolGroupGlyph(row))

return (
<div
className={`shiny-chat-tool-group shiny-chat-tool-group--${
single ? "single" : "multi"
}`}
}${framed ? " shiny-chat-tool-group--framed" : ""}`}
>
<button
type="button"
Expand Down
82 changes: 82 additions & 0 deletions js/src/chat/chat-tools.scss
Original file line number Diff line number Diff line change
Expand Up @@ -698,10 +698,29 @@ shinychat-raw-html {
list-style: none;
margin: 0;
padding: 0;
position: relative;

&[hidden] {
display: none;
}

// The group header's own drill-in rule: a straight vertical line under the
// header's glyph, running the full height of the call list down through the
// final tool result. A pseudo-element rather than a border so the rows'
// geometry is untouched. The x-position follows the same convention as the
// leaf cards' rules (see the __detail padding below): the line's inline
// start edge sits on the glyph's center.
&::before {
content: "";
position: absolute;
inset-block: 0;
inset-inline-start: calc(var(--_row-pad) + var(--_row-glyph-width) / 2);
width: $shiny-chat-tool-detail-rule;
background-color: var(--bs-border-color);
// Match the dimming the rows and leaf cards get, so the line reads as the
// same subordinate-activity chrome rather than a stronger rule.
opacity: var(--_activity-opacity);
}
}

.shiny-chat-tool-call-row {
Expand Down Expand Up @@ -819,6 +838,69 @@ shinychat-raw-html {
padding-inline-start: calc(var(--_row-pad) + var(--_row-glyph-width) / 2);
}

.shiny-chat-tool-group--framed,
.shiny-chat-tool-call-row--framed {
border: var(--bs-card-border-width, 1px) solid var(--bs-card-border-color);
border-radius: var(--bs-border-radius, 4px);
overflow: hidden;
}

.shiny-chat-tool-group--framed > .shiny-chat-tool-group__row,
.shiny-chat-tool-call-row--framed > .shiny-chat-tool-call-row__summary {
border-radius: 0;
border-bottom: var(--bs-card-border-width, 1px) solid
var(--bs-card-border-color);
background-color: rgba(var(--bs-emphasis-color-rgb), 0.05);

&:hover,
&:focus-visible {
background-color: rgba(var(--bs-emphasis-color-rgb), 0.1);
}

&:focus-visible {
outline-offset: -2px;
}
}

// Opening a framed call row must not move or widen the box the collapsed row
// occupied: the indent moves from the summary (zeroed below) onto the frame
// itself, so the frame's border box is exactly the collapsed row's border box.
.shiny-chat-tool-call-row--framed {
margin-inline-start: var(--_row-indent);
}

.shiny-chat-tool-call-row--framed > .shiny-chat-tool-call-row__summary {
margin-left: 0;
width: 100%;
}

.shiny-chat-tool-group--framed > .shiny-chat-tool-call-row__detail,
.shiny-chat-tool-call-row--framed > .shiny-chat-tool-call-row__detail {
padding-inline-start: 0;

> .shiny-tool-card:not([fullscreen]) {
margin: 0;
border: none;
border-radius: 0;
box-shadow: none;

> .card-body {
padding-inline-start: var(--bs-card-spacer-x, 1rem);
}

> .card-footer {
padding: var(--bs-card-cap-padding-y, 0.5rem)
var(--bs-card-cap-padding-x, 1rem);
border-top: var(--bs-card-border-width, 1px) solid
var(--bs-card-border-color);
}
}
}

.shiny-chat-tool-call-row--framed + .shiny-chat-tool-call-row--framed {
margin-top: 0.5rem;
}

@keyframes shiny-chat-tool-detail-reveal {
from {
opacity: 0;
Expand Down
3 changes: 3 additions & 0 deletions js/src/chat/tool-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
isRoutableContentType,
parseToolEvents,
type ToolEvent,
type ToolResultOpenStyle,
} from "./tool-protocol"

/** How a loop's tool calls are aggregated in the condensed view. */
Expand Down Expand Up @@ -45,6 +46,7 @@ export interface ToolCallItem {
requestCall?: string
showRequest?: boolean
fullScreen?: boolean
openStyle?: ToolResultOpenStyle
expanded?: boolean
/**
* Internal wire provenance: the server wrapped an author's custom UI in a
Expand Down Expand Up @@ -132,6 +134,7 @@ function applyEvent(
if (event.footer !== undefined) item.footer = event.footer
item.showRequest = event.showRequest
item.fullScreen = event.fullScreen
item.openStyle = event.openStyle
item.expanded = event.expanded
item.customDisplay = event.customDisplay
item.resolveIndex = event.start
Expand Down
4 changes: 4 additions & 0 deletions js/src/chat/tool-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export interface ToolRequestEvent extends ToolEventSource {
arguments?: string
}

export type ToolResultOpenStyle = "minimal" | "framed"

export interface ToolResultEvent extends ToolEventSource {
kind: "result"
title?: string
Expand All @@ -55,6 +57,7 @@ export interface ToolResultEvent extends ToolEventSource {
requestCall?: string
showRequest: boolean
fullScreen: boolean
openStyle: ToolResultOpenStyle
expanded: boolean
customDisplay: boolean
footer?: string
Expand Down Expand Up @@ -240,6 +243,7 @@ function normalizeToolElement(element: ParsedToolElement): ToolEvent {
: {}),
showRequest: attrTruthy(attrs, "show-request"),
fullScreen: attrTruthy(attrs, "full-screen"),
openStyle: attrs["open-style"] === "framed" ? "framed" : "minimal",
expanded: attrTruthy(attrs, "expanded"),
customDisplay: attrTruthy(attrs, "custom-display"),
...(attrs["footer"] !== undefined ? { footer: attrs["footer"] } : {}),
Expand Down
118 changes: 118 additions & 0 deletions js/tests/chat/ToolGroup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,124 @@ describe("ToolGroup", () => {
expect(container.querySelector(".shiny-tool-card")).toBeTruthy()
})

it("frames an expanded successful single framed result", () => {
const { container } = render(
<ToolGroup
group={group({
title: "Rendered chart",
calls: [
call({
requestId: "chart",
openStyle: "framed",
value: "chart output",
valueType: "text",
footer: "<span>Chart footer</span>",
}),
],
})}
/>,
)
const groupEl = container.querySelector(".shiny-chat-tool-group")!
const row = container.querySelector(".shiny-chat-tool-group__row")!

expect(groupEl.classList.contains("shiny-chat-tool-group--framed")).toBe(
false,
)

fireEvent.click(row)

expect(groupEl.classList.contains("shiny-chat-tool-group--framed")).toBe(
true,
)
expect(groupEl.querySelectorAll(".shiny-tool-card")).toHaveLength(1)
expect(groupEl.querySelector(".card-footer")?.textContent).toBe(
"Chart footer",
)
})

it("frames only an expanded successful framed call in a multi-call group", () => {
const { container } = render(
<ToolGroup
group={group({
title: "Rendered charts",
calls: [
call({
requestId: "chart",
label: "revenue",
openStyle: "framed",
value: "chart output",
valueType: "text",
}),
call({
requestId: "table",
label: "summary",
value: "table output",
valueType: "text",
}),
],
})}
/>,
)
const groupEl = container.querySelector(".shiny-chat-tool-group")!

fireEvent.click(
container.querySelector(".shiny-chat-tool-group__row") as Element,
)
const rows = container.querySelectorAll(".shiny-chat-tool-call-row")
fireEvent.click(rows[0]!.querySelector("button") as Element)

expect(groupEl.classList.contains("shiny-chat-tool-group--framed")).toBe(
false,
)
expect(
rows[0]!.classList.contains("shiny-chat-tool-call-row--framed"),
).toBe(true)
expect(
rows[1]!.classList.contains("shiny-chat-tool-call-row--framed"),
).toBe(false)
})

it("does not frame an expanded error result", () => {
const { container } = render(
<ToolGroup
group={group({
title: "Rendered charts",
calls: [
call({
requestId: "failed-chart",
label: "revenue",
openStyle: "framed",
status: "error",
value: "chart failed",
valueType: "text",
}),
call({
requestId: "table",
label: "summary",
value: "table output",
valueType: "text",
}),
],
})}
/>,
)

fireEvent.click(
container.querySelector(".shiny-chat-tool-group__row") as Element,
)
const errorRow = container.querySelectorAll(".shiny-chat-tool-call-row")[0]!
fireEvent.click(errorRow.querySelector("button") as Element)

expect(
container
.querySelector(".shiny-chat-tool-group")
?.classList.contains("shiny-chat-tool-group--framed"),
).toBe(false)
expect(
errorRow.classList.contains("shiny-chat-tool-call-row--framed"),
).toBe(false)
})

it("shows a title: label colon form for a single call with a label", () => {
const { container } = render(
<ToolGroup
Expand Down
8 changes: 5 additions & 3 deletions js/tests/chat/tool-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ import { parseToolEvents } from "../../src/chat/tool-protocol"

const request = (id: string, title: string) =>
`<shiny-tool-request request-id="${id}" tool-name="search" tool-title="${title}"></shiny-tool-request>`
const result = (id: string, title: string) =>
`<shiny-tool-result request-id="${id}" tool-name="search" status="success" tool-title="${title}"></shiny-tool-result>`
const result = (id: string, title: string, extraAttrs = "") =>
`<shiny-tool-result request-id="${id}" tool-name="search" status="success" tool-title="${title}" ${extraAttrs}></shiny-tool-result>`

describe("tool lifecycle model", () => {
it("pairs request and result elements into one settled call", () => {
const events = parseToolEvents(
request("r1", "Searching") + result("r1", "Searched"),
request("r1", "Searching") +
result("r1", "Searched", 'open-style="framed"'),
"markdown",
)
const calls = pairToolEvents(events, "0:0", 0)
Expand All @@ -25,6 +26,7 @@ describe("tool lifecycle model", () => {
status: "success",
definitionTitle: "Searching",
title: "Searched",
openStyle: "framed",
})
})

Expand Down
35 changes: 35 additions & 0 deletions js/tests/chat/tool-presentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,41 @@ describe("tool presentation projection", () => {
})
})

it("keeps a successful framed result in its activity row", () => {
const framed = call({
openStyle: "framed",
value: "forecast",
valueType: "text",
})

const presentation = projectToolGroup(group([framed]))

expect(presentation.row?.calls).toEqual([framed])
expect(presentation.standalonePayloads).toEqual([])
})

it("keeps custom-display migration authoritative over framed presentation", () => {
const customFramed = call({
customDisplay: true,
openStyle: "framed",
value: "<table></table>",
valueType: "html",
})

const presentation = projectToolGroup(group([customFramed]))

expect(presentation.row).toBeNull()
expect(presentation.standalonePayloads).toEqual([
{
key: "request-1",
call: customFramed,
value: "<table></table>",
valueType: "html",
showRequest: false,
},
])
})

it("keeps a running custom-display request in the activity row", () => {
const running = call({
status: "running",
Expand Down
Loading
Loading