Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions js/dist/shinychat.js

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions js/src/chat/chat-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ function parseInitialMessages(container: HTMLElement): ChatMessageData[] {
streaming: false,
icon,
blocks: [{ type: "content", content, contentType }],
fromMarkup: true,
})
})

Expand Down
9 changes: 8 additions & 1 deletion js/src/chat/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ export interface ChatMessageData {
fenceMarker?: string
/** True when the stream was cancelled by the user before it completed. */
cancelled?: boolean
/**
* True for messages parsed out of the server-rendered page markup
* (`chat_ui(messages =)`). Excluded from the reported snapshot: the markup
* re-renders them on every page load, so persisting them duplicates them on
* restore, and the server has no record of having sent them.
*/
fromMarkup?: boolean
/** Sibling navigation metadata (index within a set of edited variants, total variants). */
siblings?: { index: number; total: number }
}
Expand Down Expand Up @@ -1141,7 +1148,7 @@ function blockToSegment(block: MessageBlock): SnapshotSegment {

export function buildMessagesSnapshot(state: ChatState): SnapshotMessage[] {
return state.messages
.filter((m) => !m.isPlaceholder && !m.streaming)
.filter((m) => !m.isPlaceholder && !m.streaming && !m.fromMarkup)
.map((m) => {
const msg: SnapshotMessage = {
role: m.role,
Expand Down
35 changes: 35 additions & 0 deletions js/tests/chat/chat-entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,4 +498,39 @@ describe("browser token delivery", () => {
expect(tokenCallsAfter).toHaveLength(1)
expect(typeof tokenCallsAfter[0]![1]).toBe("string")
})

it("keeps page-markup messages out of the reported snapshot", async () => {
const host = document.createElement("shiny-chat-container")
host.setAttribute("id", "snapshot-scope")
host.innerHTML = `
<shiny-chat-messages>
<shiny-chat-message
data-role="assistant"
content-type="html"
content="&lt;shinychat-raw-html&gt;&lt;div&gt;STATIC&lt;/div&gt;&lt;/shinychat-raw-html&gt;"
></shiny-chat-message>
</shiny-chat-messages>
<shiny-chat-input placeholder="p"></shiny-chat-input>
`

await act(async () => {
document.body.appendChild(host)
})

await waitFor(() => {
expect(host.querySelector('[role="textbox"]')).not.toBeNull()
})

const setInputValue = window.Shiny!.setInputValue as ReturnType<
typeof vi.fn
>
const snapshotCalls = setInputValue.mock.calls.filter(
([name]) => name === "snapshot-scope_messages:shinychat.messages",
)

expect(snapshotCalls.length).toBeGreaterThan(0)
for (const [, value] of snapshotCalls) {
expect(value).toEqual([])
}
})
})
22 changes: 22 additions & 0 deletions js/tests/chat/state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2743,4 +2743,26 @@ describe("buildMessagesSnapshot", () => {
])
expect(snap[0]!.htmlDeps).toEqual([dep])
})

it("excludes messages parsed from page markup", () => {
const s = makeState({
messages: [
makeAssistantMsg({
id: "from-markup",
content: "STATIC",
fromMarkup: true,
}),
makeAssistantMsg({ id: "from-server", content: "SENT" }),
],
})

const snap = buildMessagesSnapshot(s)

expect(snap).toEqual([
{
role: "assistant",
segments: [{ content: "SENT", content_type: "markdown" }],
},
])
})
})
2 changes: 1 addition & 1 deletion pkg-py/src/shinychat/www/GIT_VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
fddd48b4377a026e69b0a3c07f1f73b89774b9ad
9213dff4745cd51cfe21c63bf77d0a59bd5bd071
4 changes: 2 additions & 2 deletions pkg-py/src/shinychat/www/shinychat.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions pkg-py/src/shinychat/www/shinychat.js.map

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions pkg-py/tests/playwright/chat/basic/test_chat_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,12 @@ def test_validate_chat_basic(page: Page, local_app: ShinyAppProc) -> None:
expect(chat.loc_input_button).to_be_disabled()

# Verify that the message state is as expected. `.messages()` reads the
# client-reported UI snapshot, which includes the initial `ui(messages=...)`
# seed alongside the conversation added via user submissions.
# client-reported UI snapshot, which excludes the initial `ui(messages=...)`
# seed: that content is static page markup that re-renders on every load,
# so including it here would duplicate it on a bookmark/history restore.
message_state = controller.OutputCode(page, "message_state")
message_state_expected = tuple(
[
{"content": initial_message, "role": "assistant"},
{"content": f"{user_message}", "role": "user"},
{"content": f"You said: {user_message}", "role": "assistant"},
{"content": f"{user_message2}", "role": "user"},
Expand Down
7 changes: 7 additions & 0 deletions pkg-r/NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@

## Bug fixes

* `chat_restore()` now restores the chat's displayed UI from the browser's
message snapshot (server bookmark store), preserving display-only
transformations. The bookmark on a completed response now fires once the
browser reports the finished reply, so the persisted UI includes it (#272 follow-up).

* Messages passed to `chat_ui(messages = )` are no longer captured in the chat's persisted transcript. Because the page markup re-renders them on every load, restoring a bookmark previously showed each of them twice.

* Fixed `output_markdown_stream()` permanently stopping following new content after the user scrolled back to the bottom. Pinning was decided only from `scroll` events, which browsers dispatch asynchronously; if a chunk grew the container first, the user's at-bottom position no longer read as at-bottom and auto-scroll silently disengaged for good. (#282)

* `chat_app()` no longer renders a close button or registers a `stopApp()` observer when deployed to a server. Both are now gated on `rlang::is_interactive()`, preventing session crashes in multi-user deployments. (#265)
Expand Down
8 changes: 7 additions & 1 deletion pkg-r/R/chat.R
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,13 @@ chat_greeting <- function(
#' * A named list of `content` and `role`. The `content` can contain content
#' as described above, and the `role` can be "assistant" or "user".
#'
#' These messages are page markup: they re-render on every page load,
#' including a bookmark-restored one, so they are not captured in the chat's
#' persisted transcript. They are also cleared by [chat_enable_history()]'s
#' replay, so a history-enabled app loses them at the first conversation
#' switch -- send startup content with [chat_append()] instead if it needs to
#' survive either.
#'
#' @param greeting An optional greeting to display when the chat first loads.
#' Can be a [chat_greeting()] object, or a plain string (which is
#' auto-wrapped with default options). The greeting is dismissed when the
Expand Down Expand Up @@ -989,7 +996,6 @@ chat_append_stream <- function(
session = getDefaultReactiveDomain()
) {
result <- chat_append_stream_impl(id, stream, role, icon, session)
result <- chat_update_bookmark(id, result, session = session)
# History saves are triggered by the client's `_messages` echo (see the
# message_response_effect observer in chat_enable_history()), not chained
# here onto stream completion -- the browser only reports the finished
Expand Down
125 changes: 99 additions & 26 deletions pkg-r/R/chat_restore.R
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
#' App's bookmark will be automatically updated without showing a modal to the
#' user.
#'
#' Note: The `client`'s chat state and the greeting content are both
#' saved/restored automatically. If the `client`'s state doesn't properly
#' capture the chat's UI (i.e., a transformation is applied in-between
#' receiving and displaying the message), you may need to implement your own
#' `session$onRestore()` (and possibly `session$onBookmark`) handler to restore
#' any additional state.
#' Note: The chat's displayed UI is restored from the browser's own message
#' snapshot when `bookmarkStore = "server"`, so display-only transformations
#' (rendering applied between receiving a message and showing it) are preserved.
#' Under `bookmarkStore = "url"`, and for bookmarks saved before this snapshot
#' existed, restoration falls back to re-deriving the UI from the `client`'s
#' turns, which does not capture such transformations. The same fallback (with
#' a warning) covers a snapshot that can't be read back. The greeting content is
#' also saved/restored automatically.
#'
#' To avoid restoring chat history from the `client`, you can ensure that the
#' history is empty by calling `client$set_turns(list())` before passing the
Expand Down Expand Up @@ -101,6 +103,7 @@ chat_restore <- function(
# Exclude works with bookmark names
excluded_names <- session$getBookmarkExclude()
id_user_input <- paste0(id, "_user_input")
id_messages <- paste0(id, "_messages")
to_exclude <- setdiff(
paste0(
id,
Expand Down Expand Up @@ -137,6 +140,7 @@ chat_restore <- function(
client_state <- client_get_state(client)

state$values[[id]] <- client_state
bookmark_save_ui(state, session, id)
})

cancel_on_bookmark_greeting <-
Expand All @@ -147,6 +151,10 @@ chat_restore <- function(
}
})

# A submission arms one response bookmark. Consuming it when the assistant
# snapshot arrives prevents later UI replays from minting another bookmark.
response_bookmark_pending <- FALSE

cancel_set_ui <- NULL
if (restore_ui) {
cancel_set_ui <- shiny::observe(label = "set_ui", {
Expand All @@ -168,9 +176,9 @@ chat_restore <- function(
}
client_set_state(client, client_state)

# Set the UI
# Set the UI: prefer the browser's displayed snapshot, fall back to turns.
shiny::withReactiveDomain(session, {
client_set_ui(client, id = id)
bookmark_restore_ui(state, client, id, session)
})
})

Expand All @@ -191,15 +199,54 @@ chat_restore <- function(
session$input[[id_user_input]],
label = "on_user_submit_do_bookmark",
{
# On user submit
response_bookmark_pending <<- TRUE
session$doBookmark()
}
)
} else {
NULL
}

# Enable (or disable) session auto bookmarking if at least one chat wants it
cancel_mark_response_pending <-
if (bookmark_on_response && !bookmark_on_input) {
shiny::observeEvent(
session$input[[id_user_input]],
label = "mark_response_bookmark_pending",
{
response_bookmark_pending <<- TRUE
}
)
} else {
NULL
}

# Bookmark once the browser has echoed a settled transcript ending in an
# assistant reply. This must NOT fire on stream completion: the client reports
# the finished assistant message in a later round trip, so bookmarking earlier
# would persist a snapshot missing that reply (mirrors the history feature's
# message_response_effect). It also must NOT fire on the browser's echo of
# messages we populated ourselves (initial turn replay, restore, or history
# navigation).
cancel_bookmark_on_response <-
if (bookmark_on_response) {
shiny::observeEvent(
session$input[[id_messages]],
label = "on_response_do_bookmark",
ignoreInit = TRUE,
{
if (!response_bookmark_pending) {
return()
}
if (messages_end_with_assistant(get_reported_messages(session, id))) {
response_bookmark_pending <<- FALSE
session$doBookmark()
}
}
)
} else {
NULL
}

set_session_bookmark_on_response(
session,
id,
Expand Down Expand Up @@ -236,45 +283,71 @@ chat_restore <- function(
cancel_update_bookmark()
}
# observeEvent() returns an Observer with $destroy()
if (!is.null(cancel_bookmark_on_input)) cancel_bookmark_on_input$destroy()
if (!is.null(cancel_bookmark_on_input)) {
cancel_bookmark_on_input$destroy()
}
if (!is.null(cancel_mark_response_pending)) {
cancel_mark_response_pending$destroy()
}
if (!is.null(cancel_bookmark_on_response)) {
cancel_bookmark_on_response$destroy()
}
set_session_bookmark_on_response(session, id, enable = FALSE)
}

invisible(cancel_all)
}

# Method currently hooked into `chat_append_stream()` and `markdown_stream()`
# When the incoming stream ends, possibly update the URL given the `id`
# Capture the browser's displayed-message snapshot into the bookmark state so
# restore can reproduce the exact UI. Server store only: the base64 payload
# would bloat a URL bookmark, and URL/old bookmarks fall back to turn-derived UI.
bookmark_save_ui <- function(state, session, id) {
if (!is_server_bookmarkstore()) {
return(invisible())
}
state$values[[paste0(id, "_ui")]] <- encode_ui_snapshot(
get_reported_messages(session, id)
)
invisible()
}

bookmark_restore_ui <- function(state, client, id, session) {
ui_snapshot <- decode_ui_snapshot(state$values[[paste0(id, "_ui")]])
restore_chat_ui(client, id, ui_snapshot, session)
invisible()
}

messages_end_with_assistant <- function(messages) {
if (length(messages) == 0) {
return(FALSE)
}
identical(messages[[length(messages)]]$role, "assistant")
}

chat_update_bookmark <- function(
id,
stream_promise,
session = shiny::getDefaultReactiveDomain()
) {
if (!has_session_bookmark_on_response(session, id)) {
# No auto bookmark set. Return early!
return(stream_promise)
}

# Bookmark has been flagged for `id`.
# When the stream ends, update the URL.
prom <-
promises::then(stream_promise, function(stream) {
# Force a bookmark update when the stream ends!
shiny::isolate(session$doBookmark())
})

return(prom)
promises::then(stream_promise, function(stream) {
shiny::isolate(session$doBookmark())
stream
})
}

# These methods exist to set flags within the session.
# These flags will determine if the session should be bookmarked when a response has completed.
# `chat_update_bookmark()` will check if the flag is set and update the URL if it is.
ON_RESPONSE_KEY <- ".bookmark-on-response"

has_session_bookmark_on_response <- function(session, id) {
has_session_chat_bookmark_info(
session,
paste0(id, ON_RESPONSE_KEY)
)
}

set_session_bookmark_on_response <- function(session, id, enable) {
set_session_chat_bookmark_info(
session,
Expand Down
Loading
Loading