# This is a stripped-down port of the ui.Chat feature in py-shiny. The main
# things it's missing are server-side state management, i.e. the py-shiny
# version will keep the list of messages for you, and will handle the
# trimming of the message history to fit within the context window; these
# are left for the caller to handle in the R version.

#' Create a greeting for a chat UI
#'
#' @description
#' Creates a greeting object for use with [chat_ui()] or [chat_set_greeting()].
#' A greeting is displayed when the chat first loads and is dismissed when the
#' user sends their first message.
#'
#' @param content The greeting content. Can be:
#'   * A string, interpreted as markdown.
#'   * An [htmltools::HTML()] object, rendered as raw HTML.
#'   * An htmltools tag or [htmltools::tagList()], including Shiny
#'     inputs/outputs.
#'   * A generator or promise (only valid when used with `chat_set_greeting()`).
#' @param dismissible Whether the greeting is automatically dismissed when the
#'   user sends a message. Defaults to `TRUE`.
#'
#' @returns An S3 object of class `"chat_greeting"`.
#'
#' @section Patterns:
#'
#' **Non-dismissible greeting** (stays visible after the user sends a message):
#'
#' ```r
#' chat_greeting("Please read our [terms of service](https://example.com).", dismissible = FALSE)
#' ```
#'
#' **Greeting with suggestion cards** (clickable chips that fill the input):
#'
#' ```r
#' chat_greeting(paste(
#'   "## Welcome!\n\n",
#'   "Try one of these:\n\n",
#'   '<span class="suggestion">Summarize my data</span>\n',
#'   '<span class="suggestion">Create a plot</span>\n',
#'   '<span class="suggestion">Explain this code</span>'
#' ))
#' ```
#'
#' **Greeting with HTML tags** (Shiny inputs/outputs):
#'
#' ```r
#' chat_greeting(htmltools::tagList(
#'   htmltools::h2("Welcome!"),
#'   shiny::selectInput("model", "Choose a model:", c("gpt-4o", "claude-3"))
#' ))
#' ```
#'
#' @examplesIf interactive()
#' library(shiny)
#' library(bslib)
#' library(shinychat)
#'
#' ui <- page_fillable(
#'   chat_ui(
#'     "chat",
#'     greeting = chat_greeting("## Welcome!\n\nHow can I help you today?")
#'   )
#' )
#'
#' server <- function(input, output, session) {
#'   observeEvent(input$chat_user_input, {
#'     response <- paste0("You said: ", input$chat_user_input)
#'     chat_append("chat", response)
#'   })
#' }
#'
#' shinyApp(ui, server)
#'
#' @export
chat_greeting <- function(
  content,
  dismissible = TRUE
) {
  if (is.character(content) && length(content) > 1) {
    content <- paste(content, collapse = "\n")
  }
  structure(
    list(
      content = content,
      dismissible = dismissible
    ),
    class = "chat_greeting"
  )
}

#' Create a chat UI element
#'
#' @description
#' Inserts a chat UI element into a Shiny UI, which includes a scrollable
#' section for displaying chat messages, and an input field for the user to
#' enter new messages.
#'
#' To respond to user input, listen for `input$ID_user_input` (for example, if
#' `id="my_chat"`, user input will be at `input$my_chat_user_input`), and use
#' [chat_append()] to append messages to the chat.
#'
#' @section Greeting:
#'
#' A greeting is an optional welcome message shown before any conversation
#' messages. It is automatically dismissed when the user sends their first
#' message (unless created with `dismissible = FALSE`).
#'
#' **Static greeting.** Pass a string or [chat_greeting()] to the `greeting`
#' parameter:
#'
#' ```r
#' chat_ui("chat", greeting = "## Hello!\n\nHow can I help you today?")
#' ```
#'
#' **Dynamic greeting from the server.** Leave `greeting` unset and use
#' [chat_set_greeting()] from your server function. This is useful when the
#' greeting depends on session state or is generated by a model.
#'
#' **`greeting_requested` input.** When the chat is visible on the page, has
#' no messages, and has no greeting set, Shiny fires
#' `input$<id>_greeting_requested` (e.g. `input$chat_greeting_requested` for
#' `chat_ui("chat")`). The value is an event counter suitable for
#' [shiny::observeEvent()]. Use it to trigger server-side greeting generation:
#'
#' ```r
#' observeEvent(input$chat_greeting_requested, {
#'   stream <- chat_client$stream_async("Generate a short welcome message.")
#'   chat_set_greeting("chat", chat_greeting(stream))
#' })
#' ```
#'
#' This input fires when the chat component is first viewed on the page and
#' empty, and again after [chat_clear()] `(greeting = TRUE)`, enabling a
#' regenerate pattern where clearing the greeting automatically triggers a
#' fresh one.
#'
#' @param id The ID of the chat element
#' @param ... Extra HTML attributes to include on the chat element
#' @param messages A list of messages to prepopulate the chat with. Each
#'   message can be one of the following:
#'
#'   * A string, which is interpreted as markdown and rendered to HTML on
#'     the client.
#'     * To prevent interpreting as markdown, mark the string as
#'       [htmltools::HTML()].
#'   * A UI element.
#'     * This includes [htmltools::tagList()], which take UI elements
#'       (including strings) as children. In this case, strings are still
#'       interpreted as markdown as long as they're not inside HTML.
#'   * A named list of `content` and `role`. The `content` can contain content
#'     as described above, and the `role` can be "assistant" or "user".
#'
#' @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
#'   user sends their first message. For example:
#'   `greeting = chat_greeting("## Hello!\n\nHow can I help you today?")`
#' @param placeholder The placeholder text for the chat's user input field
#' @param width The CSS width of the chat element
#' @param height The CSS height of the chat element
#' @param fill Whether the chat element should try to vertically fill its
#'   container, if the container is
#'   [fillable](https://rstudio.github.io/bslib/articles/filling/index.html)
#' @param icon_assistant The icon to use for the assistant chat messages.
#'   Can be HTML or a tag in the form of [htmltools::HTML()] or
#'   [htmltools::tags()]. If `None`, a default robot icon is used.
#' @param enable_cancel If `TRUE`, show a stop button during streaming that
#'   allows the user to cancel the in-progress response. When using
#'   [chat_mod_server()], cancellation is wired up automatically. For manual
#'   usage with `chat_ui()`, observe `input$<id>_cancel` to handle cancellation
#'   (e.g., by calling `ctrl$cancel()` on an ellmer `stream_controller()`).
#'   Defaults to `FALSE`.
#' @param footer Optional HTML content to display below the chat input.
#'   This can be any HTML content (tags, tag lists, or character strings).
#'   Useful for adding disclaimers, attribution, or other information.
#'   The footer text is styled slightly smaller and lighter than body text
#'   by default. Customize with CSS properties `--shiny-chat-footer-font-size`
#'   and `--shiny-chat-footer-color` on the chat container or footer element.
#' @section Thinking display:
#'
#' When a model produces reasoning or "thinking" tokens, shinychat renders them
#' in a collapsible panel above the response. The panel shows a live stream of
#' the model's reasoning while it thinks, then auto-collapses when the response
#' begins.
#'
#' Thinking display works automatically with any model that supports it. Two
#' paths are supported:
#'
#' 1. **ellmer's `ContentThinking` objects.** Models that provide a structured
#'    thinking API (e.g., Claude with extended thinking) emit `ContentThinking`
#'    objects when you stream with `stream = "content"`. shinychat detects these
#'    and routes them to the thinking panel. This is what `chat_append()` uses
#'    internally when you pass it an ellmer content stream.
#'
#' 2. **Raw `<thinking>` tags.** Many open-source and local models (DeepSeek,
#'    QwQ, Qwen, etc.) emit `<thinking>...</thinking>` tags directly in their
#'    markdown output. shinychat detects these tags during streaming and renders
#'    the enclosed text in the thinking panel with no extra configuration.
#'
#' ## Topic labels
#'
#' You can optionally get labeled sub-sections within the thinking panel by
#' asking the model to emit `<topic>...</topic>` tags in its reasoning. These
#' are extracted and rendered as section headings inside the thinking display,
#' and the current topic appears in the collapsed header as a live status.
#'
#' To use topic labels, add something like this to your system prompt:
#'
#' ```
#' When thinking through a problem, wrap brief topic labels in <topic> tags
#' to indicate what you're currently reasoning about. For example:
#' <topic>parsing the input</topic>
#' ```
#'
#' Topic labels are entirely optional. Without them, the thinking panel still
#' works -- it just won't have sub-section headings.
#'
#' @returns A Shiny tag object, suitable for inclusion in a Shiny UI
#'
#' @examplesIf interactive()
#' library(shiny)
#' library(bslib)
#' library(shinychat)
#'
#' ui <- page_fillable(
#'   chat_ui("chat", fill = TRUE)
#' )
#'
#' server <- function(input, output, session) {
#'   observeEvent(input$chat_user_input, {
#'     # In a real app, this would call out to a chat client or API,
#'     # perhaps using the 'ellmer' package.
#'     response <- paste0(
#'       "You said:\n\n",
#'       "<blockquote>",
#'       htmltools::htmlEscape(input$chat_user_input),
#'       "</blockquote>"
#'     )
#'     chat_append("chat", response)
#'     chat_append("chat", stream)
#'   })
#' }
#'
#' shinyApp(ui, server)
#'
#' @export
chat_ui <- function(
  id,
  ...,
  messages = NULL,
  greeting = NULL,
  placeholder = "Enter a message...",
  width = "min(680px, 100%)",
  height = "auto",
  fill = TRUE,
  icon_assistant = NULL,
  enable_cancel = FALSE,
  footer = NULL
) {
  attrs <- rlang::list2(...)
  if (!all(nzchar(rlang::names2(attrs)))) {
    rlang::abort("All arguments in ... must be named.")
  }

  message_tags <- lapply(messages, function(x) {
    role <- "assistant"
    content <- x
    if (is.list(x) && ("content" %in% names(x))) {
      content <- x[["content"]]
      role <- x[["role"]] %||% role
    }

    # `content` is most likely a string, so avoid overhead in that case
    # (it's also important that we *don't escape HTML* here).
    if (is.character(content)) {
      ui <- list(html = paste(content, collapse = "\n"))
    } else {
      ui <- with_current_theme({
        htmltools::renderTags(pre_process_ui(content))
      })
    }

    tag(
      "shiny-chat-message",
      rlang::list2(
        `data-role` = role,
        content = ui[["html"]],
        icon = if (!is.null(icon_assistant)) as.character(icon_assistant),
        ui[["dependencies"]],
      )
    )
  })

  footer_tag <- NULL
  if (!is.null(footer)) {
    footer_tag <- tag("shiny-chat-footer", list(footer))
  }

  # Process greeting -------------------------------------------------------
  greeting_attr <- NULL
  greeting_deps <- list()

  if (!is.null(greeting)) {
    # Auto-wrap plain strings and HTML/tag content
    if (!inherits(greeting, "chat_greeting")) {
      greeting <- chat_greeting(greeting)
    }

    content <- greeting$content

    # Generators/promises are not allowed in chat_ui() (static context)
    if (
      inherits(content, "coro_generator_instance") ||
        promises::is.promising(content)
    ) {
      cli::cli_abort(
        c(
          "A generator or promise cannot be used as a static {.arg greeting} in {.fn chat_ui}.",
          i = "Use {.fn chat_set_greeting} from the server to set a dynamic or streaming greeting."
        )
      )
    }

    if (is.character(content) && !inherits(content, "html")) {
      # Plain markdown string
      greeting_content <- content
      greeting_content_type <- "markdown"
    } else if (
      inherits(content, "html") &&
        !inherits(content, c("shiny.tag", "shiny.tag.list"))
    ) {
      # htmltools::HTML() — raw HTML string
      greeting_content <- as.character(content)
      greeting_content_type <- "html"
    } else {
      # htmltools tag or tagList — render to HTML and collect deps
      rendered <- with_current_theme({
        htmltools::renderTags(pre_process_ui(content))
      })
      greeting_content <- rendered[["html"]]
      greeting_content_type <- "html"
      greeting_deps <- rendered[["dependencies"]]
    }

    greeting_payload <- list(
      content = greeting_content,
      content_type = greeting_content_type,
      options = list(
        dismissible = isTRUE(greeting$dismissible)
      )
    )
    greeting_attr <- jsonlite::toJSON(greeting_payload, auto_unbox = TRUE)
  }

  # Build UI ---------------------------------------------------------------
  res <- tag(
    "shiny-chat-container",
    rlang::list2(
      id = id,
      style = css(
        width = width,
        height = height
      ),
      placeholder = placeholder,
      fill = if (isTRUE(fill)) NA else NULL,
      `enable-cancel` = if (isTRUE(enable_cancel)) NA else NULL,
      # Also include icon on the parent so that when messages are dynamically added,
      # we know the default icon has changed
      `icon-assistant` = if (!is.null(icon_assistant)) {
        as.character(icon_assistant)
      },
      greeting = greeting_attr,
      ...,
      tag("shiny-chat-messages", message_tags),
      tag(
        "shiny-chat-input",
        list(id = paste0(id, "_user_input"), placeholder = placeholder)
      ),
      footer_tag,
      shinychat_deps(),
      htmltools::findDependencies(icon_assistant),
      greeting_deps
    )
  )

  if (isTRUE(fill)) {
    res <- bslib::as_fill_carrier(res)
  }

  tag_require(res, version = 5, caller = "chat_ui")
}

#' Append an assistant response (or user message) to a chat control
#'
#' @description
#' The `chat_append` function appends a message to an existing [chat_ui()]. The
#' `response` can be a string, string generator, string promise, or string
#' promise generator (as returned by the 'ellmer' package's `chat`, `stream`,
#' `chat_async`, and `stream_async` methods, respectively).
#'
#' This function should be called from a Shiny app's server. It is generally
#' used to append the client's response to the chat, while user messages are
#' added to the chat UI automatically by the front-end. You'd only need to use
#' `chat_append(role="user")` if you are programmatically generating queries
#' from the server and sending them on behalf of the user, and want them to be
#' reflected in the UI.
#'
#' # Error handling
#'
#' If the `response` argument is a generator, promise, or promise generator, and
#' an error occurs while producing the message (e.g., an iteration in
#' `stream_async` fails), the promise returned by `chat_append` will reject with
#' the error. If the `chat_append` call is the last expression in a Shiny
#' observer, shinychat will log the error message and show a message that the
#' error occurred in the chat UI.
#'
#' @param id The ID of the chat element
#' @param response The message or message stream to append to the chat element.
#'   The actual message content can one of the following:
#'
#'   * A string, which is interpreted as markdown and rendered to HTML on
#'     the client.
#'     * To prevent interpreting as markdown, mark the string as
#'       [htmltools::HTML()].
#'   * A UI element.
#'     * This includes [htmltools::tagList()], which take UI elements
#'       (including strings) as children. In this case, strings are still
#'       interpreted as markdown as long as they're not inside HTML.
#'
#' @param role The role of the message (either "assistant" or "user"). Defaults
#'   to "assistant".
#' @param icon An optional icon to display next to the message, currently only
#'   used for assistant messages. The icon can be any HTML element (e.g., an
#'   [htmltools::img()] tag) or a string of HTML.
#' @param session The Shiny session object
#'
#' @returns Returns a promise that resolves to the contents of the stream, or an
#'   error. This promise resolves when the message has been successfully sent to
#'   the client; note that it does not guarantee that the message was actually
#'   received or rendered by the client. The promise rejects if an error occurs
#'   while processing the response (see the "Error handling" section).
#'
#' @examplesIf interactive()
#' library(shiny)
#' library(coro)
#' library(bslib)
#' library(shinychat)
#'
#' # Dumbest chatbot in the world: ignores user input and chooses
#' # a random, vague response.
#' fake_chatbot <- async_generator(function(input) {
#'   responses <- c(
#'     "What does that suggest to you?",
#'     "I see.",
#'     "I'm not sure I understand you fully.",
#'     "What do you think?",
#'     "Can you elaborate on that?",
#'     "Interesting question! Let's examine thi... **See more**"
#'   )
#'
#'   await(async_sleep(1))
#'   for (chunk in strsplit(sample(responses, 1), "")[[1]]) {
#'     yield(chunk)
#'     await(async_sleep(0.02))
#'   }
#' })
#'
#' ui <- page_fillable(
#'   chat_ui("chat", fill = TRUE)
#' )
#'
#' server <- function(input, output, session) {
#'   observeEvent(input$chat_user_input, {
#'     response <- fake_chatbot(input$chat_user_input)
#'     chat_append("chat", response)
#'   })
#' }
#'
#' shinyApp(ui, server)
#'
#' @export
chat_append <- function(
  id,
  response,
  role = c("assistant", "user"),
  icon = NULL,
  session = getDefaultReactiveDomain()
) {
  check_active_session(session)
  role <- match.arg(role)

  stream <- as_generator(response)
  chat_append_stream(id, stream, role = role, icon = icon, session = session)
}

#' Low-level function to append a message to a chat control
#'
#' For advanced users who want to control the message chunking behavior. Most
#' users should use [chat_append()] instead.
#'
#' @param id The ID of the chat element
#' @param msg The message to append. Should be a named list with `role` and
#'   `content` fields. The `role` field should be either "user" or "assistant".
#'   The `content` field should be a string containing the message content, in
#'   Markdown format.
#' @param chunk Whether `msg` is just a chunk of a message, and if so, what
#'   type. If `FALSE`, then `msg` is a complete message. If `"start"`, then
#'   `msg` is the first chunk of a multi-chunk message. If `"end"`, then `msg`
#'   is the last chunk of a multi-chunk message. If `TRUE`, then `msg` is an
#'   intermediate chunk of a multi-chunk message. Default is `FALSE`.
#' @param operation The operation to perform on the message. If `"append"`,
#'   then the new content is appended to the existing message content. If
#'   `"replace"`, then the existing message content is replaced by the new
#'   content. Ignored if `chunk` is `FALSE`.
#' @param icon An optional icon to display next to the message, currently only
#'   used for assistant messages. The icon can be any HTML element (e.g.,
#'   [htmltools::img()] tag) or a string of HTML.
#' @param session The Shiny session object
#'
#' @returns Returns nothing (\code{invisible(NULL)}).
#'
#' @importFrom shiny getDefaultReactiveDomain
#'
#' @examplesIf interactive()
#' library(shiny)
#' library(coro)
#' library(bslib)
#' library(shinychat)
#'
#' # Dumbest chatbot in the world: ignores user input and chooses
#' # a random, vague response.
#' fake_chatbot <- async_generator(function(id, input) {
#'   responses <- c(
#'     "What does that suggest to you?",
#'     "I see.",
#'     "I'm not sure I understand you fully.",
#'     "What do you think?",
#'     "Can you elaborate on that?",
#'     "Interesting question! Let's examine thi... **See more**"
#'   )
#'
#'   # Use low-level chat_append_message() to temporarily set a progress message
#'   chat_append_message(id, list(role = "assistant", content = "_Thinking..._ "))
#'   await(async_sleep(1))
#'   # Clear the progress message
#'   chat_append_message(id, list(role = "assistant", content = ""), operation = "replace")
#'
#'   for (chunk in strsplit(sample(responses, 1), "")[[1]]) {
#'     yield(chunk)
#'     await(async_sleep(0.02))
#'   }
#' })
#'
#' ui <- page_fillable(
#'   chat_ui("chat", fill = TRUE)
#' )
#'
#' server <- function(input, output, session) {
#'   observeEvent(input$chat_user_input, {
#'     response <- fake_chatbot("chat", input$chat_user_input)
#'     chat_append("chat", response)
#'   })
#' }
#'
#' shinyApp(ui, server)
#'
#' @export
chat_append_message <- function(
  id,
  msg,
  chunk = TRUE,
  operation = c("append", "replace"),
  icon = NULL,
  session = getDefaultReactiveDomain()
) {
  check_active_session(session)

  if (!is.list(msg)) {
    rlang::abort("`msg` must be a named list with 'role' and 'content' fields")
  }
  if (!isTRUE(msg[["role"]] %in% c("user", "assistant"))) {
    warning("Invalid role argument; must be 'user' or 'assistant'")
    return(invisible(NULL))
  }

  if (!isFALSE(chunk)) {
    if (chunk == "start") {
      chunk_type <- "start"
    } else if (chunk == "end") {
      chunk_type <- "end"
    } else if (isTRUE(chunk)) {
      chunk_type <- "intermediate"
    } else {
      rlang::abort("Invalid chunk argument")
    }
  } else {
    chunk_type <- "complete"
  }

  content <- msg[["content"]]
  is_thinking <- inherits(content, "shinychat_thinking")
  if (is_thinking) {
    content <- unclass(content)
  }
  is_html <- inherits(
    content,
    c(
      "shiny.tag",
      "shiny.tag.list",
      "html",
      "htmlwidget",
      "shinychat_tool_card"
    )
  )
  content_type <- if (is_thinking) {
    "thinking"
  } else if (is_html) {
    "html"
  } else {
    "markdown"
  }

  operation <- match.arg(operation)

  if (is.character(content) && !is_html) {
    # content is most likely a string, so avoid overhead in that case
    ui <- list(html = content, deps = NULL)
  } else {
    # process_ui() does *not* render markdown->HTML, but it does:
    # 1. Extract and register HTMLdependency()s with the session.
    # 2. Returns a HTML string representation of the TagChild
    #    (i.e., `div()` -> `"<div>"`).
    ui <- process_ui(pre_process_ui(content), session)
  }

  msg_content <- ui[["html"]]
  if (is_html) {
    # Surround with blank lines so the markdown parser treats
    # block-level custom elements correctly.
    msg_content <- paste0("\n\n", msg_content, "\n\n")
  }

  html_deps <- ui[["deps"]]

  icon_str <- if (!is.null(icon)) as.character(icon) else NULL

  if (chunk_type == "start") {
    message_payload <- list(
      role = msg[["role"]],
      content = msg_content,
      content_type = content_type
    )
    if (!is.null(icon_str)) {
      message_payload$icon <- icon_str
    }
    action <- list(type = "chunk_start", message = message_payload)
    send_chat_action(
      id,
      action = action,
      html_deps = html_deps,
      session = session
    )
  } else if (chunk_type == "end") {
    if (nzchar(msg_content)) {
      chunk_action <- list(
        type = "chunk",
        content = msg_content,
        operation = operation,
        content_type = content_type
      )
      send_chat_action(
        id,
        action = chunk_action,
        html_deps = html_deps,
        session = session
      )
    }
    send_chat_action(id, action = list(type = "chunk_end"), session = session)
  } else if (chunk_type == "intermediate") {
    action <- list(
      type = "chunk",
      content = msg_content,
      operation = operation,
      content_type = content_type
    )
    send_chat_action(
      id,
      action = action,
      html_deps = html_deps,
      session = session
    )
  } else {
    # chunk_type == "complete"
    message_payload <- list(
      role = msg[["role"]],
      content = msg_content,
      content_type = content_type
    )
    if (!is.null(icon_str)) {
      message_payload$icon <- icon_str
    }
    action <- list(type = "message", message = message_payload)
    send_chat_action(
      id,
      action = action,
      html_deps = html_deps,
      session = session
    )
  }

  invisible(NULL)
}

chat_append_stream <- function(
  id,
  stream,
  role = "assistant",
  icon = NULL,
  session = getDefaultReactiveDomain()
) {
  result <- chat_append_stream_impl(id, stream, role, icon, session)
  result <- chat_update_bookmark(id, result, session = session)
  # Handle erroneous result...
  result <- promises::catch(result, function(reason) {
    # ...but rethrow the error as a silent error, so the caller can also handle
    # it if they want, but it won't bring down the app.
    class(reason) <- c("shiny.silent.error", class(reason))
    cnd_signal(reason)
  })

  promises::catch(result, function(reason) {
    chat_append_message(
      id,
      list(
        role = role,
        content = sanitized_chat_error(reason)
      ),
      chunk = "end",
      operation = "append",
      session = session
    )
    rlang::warn(
      sprintf(
        "ERROR: An error occurred in `chat_append_stream(id=\"%s\")`",
        session$ns(id)
      ),
      parent = reason
    )
  })

  # Note that we're not returning the result of `promises::catch()`, because we
  # want to return a rejected promise so the caller can see the error. But we
  # use the `catch()` both to make the error visible to the user *and* to ensure
  # there's no "unhandled promise error" warning if the caller chooses not to do
  # anything with it.
  result
}

utils:::globalVariables(c("generator_env", "exits", "yield"))

chat_append_stream_impl <- NULL
rlang::on_load(
  chat_append_stream_impl <- coro::async(function(
    id,
    stream,
    role = "assistant",
    icon = NULL,
    session = shiny::getDefaultReactiveDomain()
  ) {
    chat_append_ <- function(content, chunk = TRUE, ...) {
      chat_append_message(
        id,
        msg = list(role = role, content = content),
        operation = "append",
        chunk = chunk,
        session = session,
        ...
      )
    }

    chat_append_("", chunk = "start", icon = icon)

    res <- fastmap::fastqueue(200)

    for (msg in stream) {
      if (promises::is.promising(msg)) {
        msg <- await(msg)
      }
      if (coro::is_exhausted(msg)) {
        break
      }

      res$add(msg)

      if (S7::S7_inherits(msg, ellmer::ContentToolResult)) {
        if (!is.null(msg@request)) {
          send_chat_action(
            id,
            action = list(
              type = "hide_tool_request",
              requestId = msg@request@id
            ),
            session = session
          )
        }
      }

      if (S7::S7_inherits(msg, ellmer::Content)) {
        msg <- contents_shinychat(msg)
      }

      chat_append_(msg)
    }

    chat_append_("", chunk = "end")

    res <- res$as_list()
    if (every(res, is.character)) {
      paste(unlist(res), collapse = "")
    } else {
      res
    }
  })
)

#' Set the greeting for a chat UI
#'
#' @description
#' Sets or clears the greeting displayed in an existing [chat_ui()]. The
#' greeting is shown when the chat first loads and is dismissed when the user
#' sends their first message.
#'
#' Call `chat_set_greeting()` from the server to display a dynamic or streaming
#' greeting. This is typically used inside an [shiny::observeEvent()] watching
#' `input$<id>_greeting_requested` -- an event that fires when the chat is
#' visible, has no messages, and has no greeting set. See the **Greeting**
#' section in [chat_ui()] for details on `greeting_requested`.
#'
#' If the greeting has already been dismissed, calling this function updates
#' the greeting content but does not make it visible again. To show a new
#' greeting after dismissal, first clear the chat with
#' `chat_clear(id, greeting = TRUE)`.
#'
#' Pass `NULL` to clear the current greeting entirely.
#'
#' @param id The ID of the chat element
#' @param greeting The greeting to display. Can be:
#'   * `NULL` to clear the current greeting.
#'   * A plain string or [htmltools::HTML()] object (auto-wrapped with default
#'     options).
#'   * A [chat_greeting()] object.
#' @param session The Shiny session object
#'
#' @returns Returns `invisible(NULL)` for static and NULL greetings, or a
#'   promise for streaming greetings (resolves when streaming is complete).
#'
#' @examplesIf interactive()
#' library(shiny)
#' library(bslib)
#' library(shinychat)
#'
#' # Static greeting set from the server
#' ui <- page_fillable(chat_ui("chat"))
#'
#' server <- function(input, output, session) {
#'   chat_set_greeting("chat", "## Welcome!\n\nHow can I help you today?")
#'
#'   observeEvent(input$chat_user_input, {
#'     chat_append("chat", paste0("You said: ", input$chat_user_input))
#'   })
#' }
#'
#' shinyApp(ui, server)
#'
#' @examplesIf interactive()
#' library(shiny)
#' library(bslib)
#' library(shinychat)
#' library(coro)
#'
#' # Streaming greeting generated by a function
#' greeting_generator <- async_generator(function() {
#'   for (chunk in strsplit("## Hello!\n\nHow can I help you?", "")[[1]]) {
#'     yield(chunk)
#'     await(async_sleep(0.02))
#'   }
#' })
#'
#' ui <- page_fillable(chat_ui("chat"))
#'
#' server <- function(input, output, session) {
#'   chat_set_greeting("chat", chat_greeting(greeting_generator()))
#'
#'   observeEvent(input$chat_user_input, {
#'     chat_append("chat", paste0("You said: ", input$chat_user_input))
#'   })
#' }
#'
#' shinyApp(ui, server)
#'
#' @examplesIf interactive()
#' library(shiny)
#' library(bslib)
#' library(shinychat)
#'
#' # LLM-generated greeting using greeting_requested
#' ui <- page_fillable(chat_ui("chat"))
#'
#' server <- function(input, output, session) {
#'   chat_client <- ellmer::chat_openai(model = "gpt-4o")
#'
#'   observeEvent(input$chat_greeting_requested, {
#'     stream <- chat_client$stream_async(
#'       "Generate a short, friendly welcome message."
#'     )
#'     chat_set_greeting("chat", chat_greeting(stream))
#'   })
#'
#'   observeEvent(input$chat_user_input, {
#'     stream <- chat_client$stream_async(input$chat_user_input)
#'     chat_append("chat", stream)
#'   })
#' }
#'
#' shinyApp(ui, server)
#'
#' @examplesIf interactive()
#' library(shiny)
#' library(bslib)
#' library(shinychat)
#'
#' # Regenerate pattern: chat_clear(greeting = TRUE) triggers greeting_requested
#' ui <- page_fillable(
#'   chat_ui("chat"),
#'   actionButton("regenerate", "New greeting")
#' )
#'
#' server <- function(input, output, session) {
#'   observeEvent(input$chat_greeting_requested, {
#'     chat_set_greeting(
#'       "chat",
#'       paste("## Welcome!\n\nGenerated at", Sys.time())
#'     )
#'   })
#'
#'   observeEvent(input$regenerate, {
#'     chat_clear("chat", greeting = TRUE)
#'   })
#'
#'   observeEvent(input$chat_user_input, {
#'     chat_append("chat", paste0("You said: ", input$chat_user_input))
#'   })
#' }
#'
#' shinyApp(ui, server)
#'
#' @export
chat_set_greeting <- function(
  id,
  greeting,
  session = getDefaultReactiveDomain()
) {
  check_active_session(session)

  if (is.null(greeting)) {
    send_chat_action(
      id,
      action = list(type = "greeting_clear"),
      session = session
    )
    return(invisible(NULL))
  }

  if (!inherits(greeting, "chat_greeting")) {
    greeting <- chat_greeting(greeting)
  }

  content <- greeting$content
  options <- list(dismissible = isTRUE(greeting$dismissible))

  if (
    inherits(content, "coro_generator_instance") ||
      promises::is.promising(content)
  ) {
    stream <- as_generator(content)
    result <- chat_set_greeting_stream(id, stream, options, session)
    result <- promises::catch(result, function(reason) {
      class(reason) <- c("shiny.silent.error", class(reason))
      cnd_signal(reason)
    })
    promises::catch(result, function(reason) {
      send_chat_action(
        id,
        action = list(type = "greeting_end"),
        session = session
      )
      rlang::warn(
        sprintf(
          "ERROR: An error occurred in `chat_set_greeting(id=\"%s\")`",
          session$ns(id)
        ),
        parent = reason
      )
    })
    return(result)
  }

  if (is.function(content)) {
    cli::cli_abort(c(
      "{.fn chat_set_greeting} does not accept a function as greeting content.",
      "i" = "Pass the {.emph result} of calling your function, not the function itself.",
      "i" = "To use a greeting function with automatic lifecycle management, pass it to the {.arg greeting} argument of {.fn chat_mod_server}."
    ))
  }

  if (is.character(content) && !inherits(content, "html")) {
    greeting_content <- content
    greeting_content_type <- "markdown"
    html_deps <- NULL
  } else if (
    inherits(content, "html") &&
      !inherits(content, c("shiny.tag", "shiny.tag.list"))
  ) {
    greeting_content <- as.character(content)
    greeting_content_type <- "html"
    html_deps <- NULL
  } else {
    ui <- process_ui(pre_process_ui(content), session)
    greeting_content <- ui[["html"]]
    greeting_content_type <- "html"
    html_deps <- ui[["deps"]]
  }

  action <- list(
    type = "greeting",
    content = greeting_content,
    content_type = greeting_content_type,
    options = options
  )
  send_chat_action(
    id,
    action = action,
    html_deps = html_deps,
    session = session
  )
  invisible(NULL)
}

chat_set_greeting_stream <- NULL
rlang::on_load(
  chat_set_greeting_stream <- coro::async(function(
    id,
    stream,
    options,
    session = shiny::getDefaultReactiveDomain()
  ) {
    send_greeting_action <- function(action) {
      send_chat_action(id, action = action, session = session)
    }

    send_greeting_action(list(
      type = "greeting_start",
      content = "",
      content_type = "markdown",
      options = options
    ))

    for (msg in stream) {
      if (promises::is.promising(msg)) {
        msg <- await(msg)
      }
      if (coro::is_exhausted(msg)) {
        break
      }

      if (is.character(msg) && !inherits(msg, "html")) {
        ui <- list(html = msg, deps = NULL)
        chunk_content_type <- "markdown"
      } else {
        ui <- process_ui(pre_process_ui(msg), session)
        chunk_content_type <- "html"
      }

      send_chat_action(
        id,
        action = list(
          type = "greeting_chunk",
          content = ui[["html"]],
          operation = "append",
          content_type = chunk_content_type
        ),
        html_deps = ui[["deps"]],
        session = session
      )
    }

    send_greeting_action(list(type = "greeting_end"))
    invisible(NULL)
  })
)

#' Clear all messages from a chat control
#'
#' @description
#' Removes all messages from the chat UI. Set `greeting = TRUE` to also
#' clear the greeting, which re-triggers `greeting_requested` (see the
#' **Greeting** section in [chat_ui()]).
#'
#' @param id The ID of the chat element
#' @param greeting If `TRUE`, also clears the greeting. When the greeting is
#'   cleared, `greeting_requested` will fire again (if the chat is visible),
#'   allowing the server to generate a new greeting.
#' @param session The Shiny session object
#'
#' @export
#' @examplesIf interactive()
#'
#' library(shiny)
#' library(bslib)
#'
#' ui <- page_fillable(
#'   chat_ui("chat", fill = TRUE),
#'   actionButton("clear", "Clear chat")
#' )
#'
#' server <- function(input, output, session) {
#'   observeEvent(input$clear, {
#'     chat_clear("chat")
#'   })
#'
#'   observeEvent(input$chat_user_input, {
#'     response <- paste0("You said: ", input$chat_user_input)
#'     chat_append("chat", response)
#'   })
#' }
#'
#' shinyApp(ui, server)
#'
#' @examplesIf interactive()
#'
#' library(shiny)
#' library(bslib)
#' library(shinychat)
#'
#' # Regenerate greeting on clear
#' ui <- page_fillable(
#'   chat_ui("chat"),
#'   actionButton("new_chat", "New chat")
#' )
#'
#' server <- function(input, output, session) {
#'   observeEvent(input$chat_greeting_requested, {
#'     chat_set_greeting("chat", "## Welcome!\n\nHow can I help?")
#'   })
#'
#'   observeEvent(input$new_chat, {
#'     # Clearing with greeting = TRUE triggers greeting_requested again
#'     chat_clear("chat", greeting = TRUE)
#'   })
#'
#'   observeEvent(input$chat_user_input, {
#'     chat_append("chat", paste0("You said: ", input$chat_user_input))
#'   })
#' }
#'
#' shinyApp(ui, server)
chat_clear <- function(
  id,
  greeting = FALSE,
  session = getDefaultReactiveDomain()
) {
  check_active_session(session)
  action <- list(type = "clear")
  if (isTRUE(greeting)) {
    action$greeting <- TRUE
  }
  send_chat_action(id, action = action, session = session)
}

#' Update the user input of a chat control
#'
#' @param id The ID of the chat element
#' @param ... Currently unused, but reserved for future use.
#' @param value The value to set the user input to. If `NULL`, the input will not be updated.
#' @param placeholder The placeholder text for the user input
#' @param submit Whether to automatically submit the text for the user. Requires `value`.
#' @param focus Whether to move focus to the input element. Requires `value`.
#' @param session The Shiny session object
#'
#' @export
#' @examplesIf interactive()
#' library(shiny)
#' library(bslib)
#' library(shinychat)
#'
#' ui <- page_fillable(
#'   chat_ui("chat"),
#'   layout_columns(
#'     fill = FALSE,
#'     actionButton("update_placeholder", "Update placeholder"),
#'     actionButton("update_value", "Update user input")
#'   )
#' )
#'
#' server <- function(input, output, session) {
#'   observeEvent(input$update_placeholder, {
#'     update_chat_user_input("chat", placeholder = "New placeholder text")
#'   })
#'
#'   observeEvent(input$update_value, {
#'     update_chat_user_input("chat", value = "New user input", focus = TRUE)
#'   })
#'
#'   observeEvent(input$chat_user_input, {
#'     response <- paste0("You said: ", input$chat_user_input)
#'     chat_append("chat", response)
#'   })
#' }
#'
#' shinyApp(ui, server)

update_chat_user_input <- function(
  id,
  ...,
  value = NULL,
  placeholder = NULL,
  submit = FALSE,
  focus = FALSE,
  session = getDefaultReactiveDomain()
) {
  rlang::check_dots_empty()
  check_active_session(session)

  if (is.null(value) && (submit || focus)) {
    rlang::abort(
      "An input `value` must be provided when `submit` or `focus` are `TRUE`."
    )
  }

  vals <- drop_nulls(
    list(
      value = value,
      placeholder = placeholder,
      submit = submit,
      focus = focus
    )
  )

  action <- c(list(type = "update_input"), vals)

  send_chat_action(id, action = action, session = session)
}
