layered-assistant-rails 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/NOTICE +20 -0
  3. data/app/controllers/layered/assistant/assistants_controller.rb +4 -4
  4. data/app/controllers/layered/assistant/conversations_controller.rb +4 -4
  5. data/app/controllers/layered/assistant/messages_controller.rb +1 -1
  6. data/app/controllers/layered/assistant/models_controller.rb +1 -1
  7. data/app/controllers/layered/assistant/panel/conversations_controller.rb +3 -3
  8. data/app/controllers/layered/assistant/personas_controller.rb +1 -1
  9. data/app/controllers/layered/assistant/providers_controller.rb +1 -1
  10. data/app/controllers/layered/assistant/public/assistants_controller.rb +1 -1
  11. data/app/controllers/layered/assistant/public/conversations_controller.rb +2 -2
  12. data/app/controllers/layered/assistant/public/panel/conversations_controller.rb +3 -3
  13. data/app/controllers/layered/assistant/skills_controller.rb +1 -1
  14. data/app/helpers/layered/assistant/messages_helper.rb +0 -88
  15. data/app/javascript/layered_assistant/composer_controller.js +0 -1
  16. data/app/javascript/layered_assistant/index.js +2 -0
  17. data/app/javascript/layered_assistant/markdown_controller.js +19 -0
  18. data/app/javascript/layered_assistant/marked_setup.js +20 -0
  19. data/app/javascript/layered_assistant/message_streaming.js +35 -53
  20. data/app/javascript/layered_assistant/messages_controller.js +1 -1
  21. data/app/javascript/layered_assistant/vendor/dompurify.LICENSE +568 -0
  22. data/app/javascript/layered_assistant/vendor/dompurify.js +1471 -0
  23. data/app/javascript/layered_assistant/vendor/marked.LICENSE +44 -0
  24. data/app/javascript/layered_assistant/vendor/marked.js +8 -0
  25. data/app/jobs/layered/assistant/messages/response_job.rb +1 -1
  26. data/app/models/layered/assistant/message.rb +2 -22
  27. data/app/services/layered/assistant/messages_service.rb +2 -2
  28. data/app/views/layered/assistant/assistants/_form.html.erb +7 -7
  29. data/app/views/layered/assistant/assistants/index.html.erb +7 -7
  30. data/app/views/layered/assistant/conversations/_form.html.erb +3 -3
  31. data/app/views/layered/assistant/conversations/index.html.erb +7 -7
  32. data/app/views/layered/assistant/conversations/show.html.erb +2 -2
  33. data/app/views/layered/assistant/messages/_composer_fields.html.erb +3 -3
  34. data/app/views/layered/assistant/messages/_message.html.erb +5 -6
  35. data/app/views/layered/assistant/messages/create.turbo_stream.erb +1 -1
  36. data/app/views/layered/assistant/messages/index.html.erb +6 -6
  37. data/app/views/layered/assistant/models/_form.html.erb +4 -4
  38. data/app/views/layered/assistant/models/index.html.erb +7 -7
  39. data/app/views/layered/assistant/panel/conversations/_header.html.erb +3 -3
  40. data/app/views/layered/assistant/panel/conversations/index.html.erb +6 -6
  41. data/app/views/layered/assistant/panel/conversations/new.html.erb +4 -4
  42. data/app/views/layered/assistant/panel/messages/create.turbo_stream.erb +1 -1
  43. data/app/views/layered/assistant/personas/_form.html.erb +3 -3
  44. data/app/views/layered/assistant/personas/index.html.erb +7 -7
  45. data/app/views/layered/assistant/providers/_form.html.erb +5 -5
  46. data/app/views/layered/assistant/providers/index.html.erb +7 -7
  47. data/app/views/layered/assistant/public/assistants/index.html.erb +3 -3
  48. data/app/views/layered/assistant/public/conversations/show.html.erb +3 -3
  49. data/app/views/layered/assistant/public/messages/create.turbo_stream.erb +1 -1
  50. data/app/views/layered/assistant/public/panel/conversations/_header.html.erb +3 -3
  51. data/app/views/layered/assistant/public/panel/conversations/index.html.erb +5 -5
  52. data/app/views/layered/assistant/public/panel/conversations/new.html.erb +2 -2
  53. data/app/views/layered/assistant/public/panel/messages/create.turbo_stream.erb +1 -1
  54. data/app/views/layered/assistant/setup/_setup.html.erb +54 -54
  55. data/app/views/layered/assistant/skills/_form.html.erb +3 -3
  56. data/app/views/layered/assistant/skills/index.html.erb +7 -7
  57. data/config/importmap.rb +4 -0
  58. data/config/routes.rb +15 -15
  59. data/data/models.json +17 -17
  60. data/lib/layered/assistant/version.rb +1 -1
  61. data/lib/layered/assistant.rb +0 -2
  62. metadata +23 -31
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9419d936b5247ec519e481b02b7ead93a0e85bd6720a7b4298d145810c1405be
4
- data.tar.gz: 5f9058cdae99fa50b3bf704853b74205f9a544e8c8430611ceb6ba1a509356bf
3
+ metadata.gz: e20ccd66f325c50ccfa813d277572f2d6c4a4460145fa21697adfef42c2a4e25
4
+ data.tar.gz: 109f84519abfcc651e7b0b2c89386cddb6a6c92ee51dddca8b2b43b3ebd1ecae
5
5
  SHA512:
6
- metadata.gz: d5111d8c91517082ac5ab5eea935df4fbf73f0952c7455fcf6ac98f1fadab19f842aa0bf2483c60cdf1d8aaf2cfdb2cb812f488e7352195586038337f3e2314a
7
- data.tar.gz: 3776256743b2734f9ae369508770e8cdc327e17299de34d01af376ba594485b4e55d2b160ce4b4820968c657fedcca2ede227067810a7dcb6f134e29dc5dd7ca
6
+ metadata.gz: e68add80616b9619fc97bad66081be5f40013c5354360c06247a9ea05e44985b639ed683e8794edbbe0abf791d7f53cb0887c442483b28b0b2b617586ea80dd3
7
+ data.tar.gz: 1f22e1f958687aa943a0623316fde162afc517c3c3ca6f299ee1c30e72a36c4b676c00bf7f0af977200489612eaaa009662c332fbd000abf8bd6e587dc051b4e
data/NOTICE CHANGED
@@ -5,3 +5,23 @@ Copyright 2026 LAYERED AI LIMITED (UK company number: 17056830).
5
5
  This product includes software developed by LAYERED AI LIMITED and contributors.
6
6
 
7
7
  Licensed under the Apache License, Version 2.0.
8
+
9
+ ------------------------------------------------------------------------
10
+ Third-party software
11
+ ------------------------------------------------------------------------
12
+
13
+ This product bundles the following third-party JavaScript libraries
14
+ under app/javascript/layered_assistant/vendor/. Their full license
15
+ texts are included alongside the source files.
16
+
17
+ marked (https://github.com/markedjs/marked)
18
+ Copyright (c) 2018+ MarkedJS
19
+ Copyright (c) 2011-2018 Christopher Jeffrey
20
+ Licensed under the MIT License.
21
+ See: app/javascript/layered_assistant/vendor/marked.LICENSE
22
+
23
+ DOMPurify (https://github.com/cure53/DOMPurify)
24
+ Copyright Dr.-Ing. Mario Heiderich, Cure53
25
+ Dual-licensed under the Apache License 2.0 or the
26
+ Mozilla Public License 2.0; used here under Apache-2.0.
27
+ See: app/javascript/layered_assistant/vendor/dompurify.LICENSE
@@ -1,10 +1,10 @@
1
1
  module Layered
2
2
  module Assistant
3
3
  class AssistantsController < ApplicationController
4
- before_action :set_assistant, only: [:edit, :update, :destroy]
5
- before_action :set_models, only: [:new, :create, :edit, :update]
6
- before_action :set_personas, only: [:new, :create, :edit, :update]
7
- before_action :set_skills, only: [:new, :create, :edit, :update]
4
+ before_action :set_assistant, only: [ :edit, :update, :destroy ]
5
+ before_action :set_models, only: [ :new, :create, :edit, :update ]
6
+ before_action :set_personas, only: [ :new, :create, :edit, :update ]
7
+ before_action :set_skills, only: [ :new, :create, :edit, :update ]
8
8
 
9
9
  def index
10
10
  @page_title = "Assistants"
@@ -3,8 +3,8 @@ module Layered
3
3
  class ConversationsController < ApplicationController
4
4
  include StoppableResponse
5
5
 
6
- before_action :set_conversation, only: [:show, :edit, :update, :destroy, :stop]
7
- before_action :set_assistants, only: [:new, :create]
6
+ before_action :set_conversation, only: [ :show, :edit, :update, :destroy, :stop ]
7
+ before_action :set_assistants, only: [ :new, :create ]
8
8
 
9
9
  def index
10
10
  if params[:assistant_id]
@@ -26,7 +26,7 @@ module Layered
26
26
 
27
27
  def new
28
28
  @page_title = "New conversation"
29
- @conversation = Conversation.new(params.permit(conversation: [:assistant_id])[:conversation])
29
+ @conversation = Conversation.new(params.permit(conversation: [ :assistant_id ])[:conversation])
30
30
  end
31
31
 
32
32
  def create
@@ -35,7 +35,7 @@ module Layered
35
35
  @conversation.assistant = scoped(Assistant).find(conversation_params[:assistant_id]) if conversation_params[:assistant_id].present?
36
36
  @conversation.name = Conversation.default_name if @conversation.name.blank?
37
37
  if @conversation.save
38
- redirect_to layered_assistant.conversation_path(@conversation), notice: "Conversation was successfully created."
38
+ redirect_to layered_assistant.conversation_path(@conversation)
39
39
  else
40
40
  render :new, status: :unprocessable_entity
41
41
  end
@@ -4,7 +4,7 @@ module Layered
4
4
  include MessageCreation
5
5
 
6
6
  before_action :set_conversation
7
- before_action :set_message, only: [:destroy]
7
+ before_action :set_message, only: [ :destroy ]
8
8
 
9
9
  def index
10
10
  @page_title = "Messages"
@@ -2,7 +2,7 @@ module Layered
2
2
  module Assistant
3
3
  class ModelsController < ApplicationController
4
4
  before_action :set_provider
5
- before_action :set_model, only: [:edit, :update, :destroy]
5
+ before_action :set_model, only: [ :edit, :update, :destroy ]
6
6
 
7
7
  def index
8
8
  @pagy, @models = pagy(@provider.models.sorted)
@@ -5,9 +5,9 @@ module Layered
5
5
  include StoppableResponse
6
6
  layout false
7
7
 
8
- before_action :set_conversation, only: [:show, :destroy, :stop]
9
- before_action :set_conversations, only: [:index, :show]
10
- before_action :set_assistants, only: [:index, :show, :new, :create]
8
+ before_action :set_conversation, only: [ :show, :destroy, :stop ]
9
+ before_action :set_conversations, only: [ :index, :show ]
10
+ before_action :set_assistants, only: [ :index, :show, :new, :create ]
11
11
 
12
12
  def index
13
13
  end
@@ -1,7 +1,7 @@
1
1
  module Layered
2
2
  module Assistant
3
3
  class PersonasController < ApplicationController
4
- before_action :set_persona, only: [:edit, :update, :destroy]
4
+ before_action :set_persona, only: [ :edit, :update, :destroy ]
5
5
 
6
6
  def index
7
7
  @page_title = "Personas"
@@ -1,7 +1,7 @@
1
1
  module Layered
2
2
  module Assistant
3
3
  class ProvidersController < ApplicationController
4
- before_action :set_provider, only: [:edit, :update, :destroy]
4
+ before_action :set_provider, only: [ :edit, :update, :destroy ]
5
5
 
6
6
  def index
7
7
  @page_title = "Providers"
@@ -2,7 +2,7 @@ module Layered
2
2
  module Assistant
3
3
  module Public
4
4
  class AssistantsController < ApplicationController
5
- before_action :set_public_assistant, only: [:show]
5
+ before_action :set_public_assistant, only: [ :show ]
6
6
 
7
7
  def index
8
8
  @pagy, @assistants = pagy(Assistant.publicly_available.by_name)
@@ -4,8 +4,8 @@ module Layered
4
4
  class ConversationsController < ApplicationController
5
5
  include StoppableResponse
6
6
 
7
- before_action :set_public_assistant, only: [:create]
8
- before_action :set_conversation, only: [:show, :stop]
7
+ before_action :set_public_assistant, only: [ :create ]
8
+ before_action :set_conversation, only: [ :show, :stop ]
9
9
 
10
10
  def create
11
11
  @conversation = @assistant.conversations.new(name: Conversation.default_name)
@@ -6,9 +6,9 @@ module Layered
6
6
  include StoppableResponse
7
7
  layout false
8
8
 
9
- before_action :set_public_assistant, only: [:index, :new, :create]
10
- before_action :set_conversation, only: [:show, :stop]
11
- before_action :set_session_conversations, only: [:index, :show]
9
+ before_action :set_public_assistant, only: [ :index, :new, :create ]
10
+ before_action :set_conversation, only: [ :show, :stop ]
11
+ before_action :set_session_conversations, only: [ :index, :show ]
12
12
 
13
13
  def index
14
14
  end
@@ -1,7 +1,7 @@
1
1
  module Layered
2
2
  module Assistant
3
3
  class SkillsController < ApplicationController
4
- before_action :set_skill, only: [:edit, :update, :destroy]
4
+ before_action :set_skill, only: [ :edit, :update, :destroy ]
5
5
 
6
6
  def index
7
7
  @page_title = "Skills"
@@ -1,20 +1,6 @@
1
1
  module Layered
2
2
  module Assistant
3
3
  module MessagesHelper
4
- ALLOWED_TAGS = %w[
5
- p br
6
- h1 h2 h3 h4 h5 h6
7
- strong em s del
8
- ul ol li
9
- pre code
10
- a
11
- blockquote
12
- table thead tbody tr th td
13
- hr
14
- ].freeze
15
-
16
- ALLOWED_ATTRIBUTES = %w[href title class].freeze
17
-
18
4
  MIN_RESPONSE_MS_FOR_TPS = 100
19
5
 
20
6
  def message_metadata_title(message)
@@ -31,80 +17,6 @@ module Layered
31
17
  parts << "#{message.ttft_ms}ms TTFT" if message.ttft_ms
32
18
  parts.join(" · ")
33
19
  end
34
-
35
- def render_message_content(message)
36
- return if message.content.blank?
37
-
38
- render_markdown(message.content)
39
- end
40
-
41
- # Renders accumulated content for streaming, holding back any
42
- # trailing unclosed code fence that Kramdown can't parse correctly.
43
- # Returns { html:, has_unclosed_fence: } so the caller can decide
44
- # whether to show a typing indicator.
45
- def render_streaming_markdown(content)
46
- return { html: "", has_unclosed_fence: false } if content.blank?
47
-
48
- safe = strip_unclosed_fence(content)
49
- {
50
- html: safe.present? ? render_markdown(safe) : "",
51
- has_unclosed_fence: safe.length < content.length
52
- }
53
- end
54
-
55
- private
56
-
57
- def render_markdown(content)
58
- markdown = unwrap_markdown_fence(content)
59
- markdown = ensure_blank_line_before_tables(markdown)
60
-
61
- html = Kramdown::Document.new(
62
- markdown,
63
- input: "GFM",
64
- syntax_highlighter: nil
65
- ).to_html
66
-
67
- sanitize(html, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES)
68
- end
69
-
70
- # Some LLMs wrap their entire response in a ```markdown fence.
71
- # Strip it so Kramdown processes the inner content directly.
72
- def unwrap_markdown_fence(content)
73
- if content.start_with?("```markdown\n") && content.end_with?("\n```")
74
- content.delete_prefix("```markdown\n").delete_suffix("\n```")
75
- else
76
- content
77
- end
78
- end
79
-
80
- # Kramdown GFM requires a blank line before a table, but LLMs often
81
- # place tables directly after headings or paragraphs. Insert one
82
- # where missing so that Kramdown recognises the table syntax.
83
- def ensure_blank_line_before_tables(text)
84
- text.gsub(/([^\n])\n(\|[^\n]+\|\s*\n\|[\s:|-]+\|\s*\n)/, "\\1\n\n\\2")
85
- end
86
-
87
- # Returns text up to (but not including) any trailing unclosed code
88
- # fence. Kramdown can't produce valid output for a half-open fence,
89
- # so we hold it back until the closing marker arrives.
90
- def strip_unclosed_fence(text)
91
- fence_marker = nil
92
- fence_start = 0
93
- pos = 0
94
-
95
- text.each_line do |line|
96
- trimmed = line.lstrip
97
- if fence_marker.nil? && (match = trimmed.match(/\A(`{3,}|~{3,})/))
98
- fence_marker = match[1]
99
- fence_start = pos
100
- elsif fence_marker && trimmed.match?(/\A#{Regexp.escape(fence_marker[0])}{#{fence_marker.length},}\s*\z/)
101
- fence_marker = nil
102
- end
103
- pos += line.length
104
- end
105
-
106
- fence_marker ? text[0, fence_start] : text
107
- end
108
20
  end
109
21
  end
110
22
  end
@@ -78,7 +78,6 @@ export default class extends Controller {
78
78
 
79
79
  const empty = this.inputTarget.value.trim() === ""
80
80
  this.sendButtonTarget.disabled = empty
81
- this.sendButtonTarget.classList.toggle("l-ui-button--disabled", empty)
82
81
  }
83
82
 
84
83
  // Toggle visibility of the Send and Stop buttons. While responding a
@@ -1,6 +1,7 @@
1
1
  import { application } from "controllers/application"
2
2
  import ComposerController from "layered_assistant/composer_controller"
3
3
  import ConversationSelectController from "layered_assistant/conversation_select_controller"
4
+ import MarkdownController from "layered_assistant/markdown_controller"
4
5
  import MessagesController from "layered_assistant/messages_controller"
5
6
  import PanelController from "layered_assistant/panel_controller"
6
7
  import PanelNavController from "layered_assistant/panel_nav_controller"
@@ -8,6 +9,7 @@ import ProviderTemplateController from "layered_assistant/provider_template_cont
8
9
 
9
10
  application.register("composer", ComposerController)
10
11
  application.register("conversation-select", ConversationSelectController)
12
+ application.register("markdown", MarkdownController)
11
13
  application.register("messages", MessagesController)
12
14
  application.register("panel", PanelController)
13
15
  application.register("panel-nav", PanelNavController)
@@ -0,0 +1,19 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { renderMarkdown } from "layered_assistant/marked_setup"
3
+
4
+ // Renders raw markdown (placed in the element by the server) as HTML
5
+ // on connect. Streaming chunks bypass this and write innerHTML
6
+ // directly via Turbo stream actions.
7
+
8
+ export default class extends Controller {
9
+ connect() {
10
+ // Skip if the element already contains rendered HTML - e.g. Turbo
11
+ // Drive cache restore, or streaming output appended by the message
12
+ // stream actions. textContent would flatten rendered HTML back to
13
+ // plain text and marked would silently drop all formatting.
14
+ if (this.element.firstElementChild) return
15
+ const text = this.element.textContent.trim()
16
+ if (!text) return
17
+ this.element.innerHTML = renderMarkdown(text)
18
+ }
19
+ }
@@ -0,0 +1,20 @@
1
+ // Markdown rendering pipeline.
2
+ //
3
+ // Marked parses LLM output (including any raw HTML it emits), then
4
+ // DOMPurify sanitises the result: it strips <script> and other unsafe
5
+ // tags, validates URL schemes (blocking javascript:, data:, vbscript:
6
+ // in href/src), and removes unsafe attributes. DOMPurify is the sole
7
+ // security boundary here.
8
+
9
+ import { marked } from "marked"
10
+ import DOMPurify from "dompurify"
11
+
12
+ marked.use({
13
+ gfm: true,
14
+ breaks: false
15
+ })
16
+
17
+ export function renderMarkdown(text) {
18
+ if (!text) return ""
19
+ return DOMPurify.sanitize(marked.parse(text))
20
+ }
@@ -1,74 +1,56 @@
1
- // Incremental markdown rendering for streamed assistant messages.
1
+ // Streaming render for assistant messages.
2
2
  //
3
- // The server broadcasts pre-rendered HTML via a custom Turbo Stream action
4
- // (render_content). This module reconciles the DOM block by block -
5
- // patching unchanged blocks in place so fade-in animations aren't
6
- // interrupted. Both streaming preview and final output use the same
7
- // server-side Kramdown pipeline, eliminating parser drift.
3
+ // The server broadcasts the raw markdown content as it grows; the
4
+ // client parses it with marked and appends newly-closed top-level
5
+ // blocks to the message content element with a fade. The trailing
6
+ // block is always held back - even when it's already complete - so
7
+ // we don't fade-restart it on every chunk; the typing indicator
8
+ // sibling covers for it visually. Trade-off: single-block responses
9
+ // stay hidden until broadcast_response_complete swaps in the final
10
+ // partial. Acceptable because the swap is near-instant and the
11
+ // indicator masks the gap.
8
12
 
9
13
  import "@hotwired/turbo-rails"
14
+ import { renderMarkdown } from "layered_assistant/marked_setup"
10
15
 
11
- const pendingHtml = new WeakMap()
16
+ const pendingMarkdown = new WeakMap()
12
17
  const pendingRender = new WeakMap()
13
18
 
14
- function reconcile(target, html) {
15
- const temp = document.createElement("div")
16
- temp.innerHTML = html
17
-
18
- // Snapshot temp.children since moves will mutate the live HTMLCollection
19
- const newBlocks = [...temp.children]
20
-
21
- // Trim excess blocks from previous render
22
- while (target.children.length > newBlocks.length) {
23
- target.lastElementChild.remove()
24
- }
25
-
26
- // Reconcile block by block - preserves existing DOM nodes (and their
27
- // running fade-in animations) wherever possible
28
- for (let i = 0; i < newBlocks.length; i++) {
29
- if (i < target.children.length && target.children[i].tagName === newBlocks[i].tagName) {
30
- // Same tag - patch content in place
31
- if (target.children[i].innerHTML !== newBlocks[i].innerHTML) {
32
- target.children[i].innerHTML = newBlocks[i].innerHTML
33
- }
34
- } else if (i < target.children.length) {
35
- // Tag changed (e.g. <p> became <table>) - replace node
36
- target.children[i].replaceWith(newBlocks[i])
37
- } else {
38
- // New block - append with fade
39
- newBlocks[i].classList.add("l-ui-token-fade")
40
- target.appendChild(newBlocks[i])
41
- }
42
- }
43
- }
44
-
45
19
  Turbo.StreamActions.enable_composer = function () {
46
20
  this.targetElements.forEach((form) => {
47
21
  form.setAttribute("data-composer-responding-value", "false")
48
22
  })
49
23
  }
50
24
 
51
- Turbo.StreamActions.render_content = function () {
52
- this.targetElements.forEach((target) => {
53
- if (!target.classList.contains("l-ui-markdown")) {
54
- target.classList.add("l-ui-markdown")
55
- }
25
+ function syncStreamingContent(target, markdown) {
26
+ const tmp = document.createElement("div")
27
+ tmp.innerHTML = renderMarkdown(markdown)
28
+ const incoming = [...tmp.children]
29
+ // Hold back the trailing in-progress block; only append new closed ones.
30
+ const closedCount = Math.max(0, incoming.length - 1)
31
+ for (let i = target.children.length; i < closedCount; i++) {
32
+ incoming[i].classList.add("l-ui-stream-fade")
33
+ target.appendChild(incoming[i])
34
+ }
35
+ if (target.children.length > 0) {
36
+ const indicator = target.parentElement?.querySelector(".l-ui-typing-indicator")
37
+ indicator?.classList.add("l-ui-mt-4")
38
+ }
39
+ }
56
40
 
57
- const html = this.templateContent
41
+ Turbo.StreamActions.render_content = function () {
42
+ const markdown = this.templateContent.textContent
58
43
 
59
- // Always store the latest HTML so the requestAnimationFrame callback
60
- // uses the newest version - convert DocumentFragment to HTML string
61
- const div = document.createElement("div")
62
- div.append(html.cloneNode(true))
63
- pendingHtml.set(target, div.innerHTML)
44
+ this.targetElements.forEach((target) => {
45
+ pendingMarkdown.set(target, markdown)
64
46
 
65
47
  if (!pendingRender.has(target)) {
66
48
  pendingRender.set(target, requestAnimationFrame(() => {
67
49
  pendingRender.delete(target)
68
- const latestHtml = pendingHtml.get(target)
69
- if (latestHtml != null) {
70
- reconcile(target, latestHtml)
71
- pendingHtml.delete(target)
50
+ const md = pendingMarkdown.get(target)
51
+ if (md != null) {
52
+ syncStreamingContent(target, md)
53
+ pendingMarkdown.delete(target)
72
54
  }
73
55
  }))
74
56
  }
@@ -14,7 +14,7 @@ export default class extends Controller {
14
14
  const body = el.closest(".l-ui-message__body")
15
15
  el.remove()
16
16
  if (body && body.children.length === 0) {
17
- body.insertAdjacentHTML("beforeend", '<div class="l-ui-notice--error" role="status">The response could not be completed.</div>')
17
+ body.insertAdjacentHTML("beforeend", '<div class="l-ui-notice l-ui-notice--error" role="status">The response could not be completed.</div>')
18
18
  }
19
19
  })
20
20
  }