Stabilize streamed Markdown lists

16136c0330f4 · AtlantisPleb · 2026-08-22T14:19:35-05:00 · parent ee240dfd9317

Stabilize streamed Markdown lists

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 100 · 2026-08-22T19:19:36.818535Z

Changed files

  • modified assets/css/openagents.css
  • modified lib/openagents/markdown.ex
  • modified test/openagents/markdown_test.exs

Diff

3 files changed, +75 -3

assets/css/openagents.css modified +11 -1

@@ -2748,7 +2748,17 @@

2748 2748
  }
2749 2749
2750 2750
  .message-markdown :is(ul, ol) {
2751
    padding-inline-start: 1.25em;
2751
    list-style-position: outside;
2752
  }
2753
2754
  .message-markdown ul {
2755
    padding-inline-start: 1.5em;
2756
    list-style-type: disc;
2757
  }
2758
2759
  .message-markdown ol {
2760
    padding-inline-start: 2em;
2761
    list-style-type: decimal;
2752 2762
  }
2753 2763
2754 2764
  .message-markdown li + li {
lib/openagents/markdown.ex modified +40 -2

@@ -72,6 +72,8 @@ defmodule OpenAgents.Markdown do

72 72
73 73
  @emphasis_markers ~w(*** ___ ** __ ~~ * _)
74 74
75
  @incomplete_block_marker ~r/^(?:\s{0,3}(?:\d{1,9}[.)]?|[#>*+\-]{1,6})\s*|\s*\|?[:|\-\s]+\|?)$/u
76
75 77
  @doc """
76 78
  Renders Markdown to safe HTML.
77 79

@@ -222,6 +224,8 @@ defmodule OpenAgents.Markdown do

222 224
      text
223 225
      |> drop_incomplete_image()
224 226
      |> flatten_incomplete_link()
227
      |> drop_incomplete_html_tag()
228
      |> drop_incomplete_block_marker()
225 229
      |> close_inline_code()
226 230
      |> close_emphasis()
227 231
    end

@@ -236,11 +240,45 @@ defmodule OpenAgents.Markdown do

236 240
237 241
  # A half-arrived image cannot be shown, so it is removed rather than rendered
238 242
  # as a broken one.
239
  defp drop_incomplete_image(text), do: String.replace(text, ~r/!\[[^\]]*\]\([^)]*\z/, "")
243
  defp drop_incomplete_image(text) do
244
    text
245
    |> String.replace(~r/!\[[^\]]*\]\([^)]*\z/u, "")
246
    |> String.replace(~r/!\[[^\]\n]*\z/u, "")
247
  end
240 248
241 249
  # A half-arrived URL must never reach an href. The text survives; the link
242 250
  # does not.
243
  defp flatten_incomplete_link(text), do: String.replace(text, ~r/\[([^\]]*)\]\([^)]*\z/, "\\1")
251
  defp flatten_incomplete_link(text) do
252
    text
253
    |> String.replace(~r/\[([^\]]*)\]\([^)]*\z/u, "\\1")
254
    |> String.replace(~r/\[([^\]\n]*)\z/u, "\\1")
255
  end
256
257
  # Streamdown's `remend` removes a tag-shaped tail until its closing `>`
258
  # arrives. Raw HTML is refused later regardless, but dropping the partial
259
  # source here also prevents `<di` from flashing as ordinary text first.
260
  defp drop_incomplete_html_tag(text) do
261
    String.replace(text, ~r/<[!?\/]?[[:alpha:]][^>\n]*\z/u, "")
262
  end
263
264
  # A block marker cannot be classified until some content follows it. For
265
  # example, `1` becomes `1. ` and then an ordered-list item; rendering each
266
  # prefix changes the DOM from a paragraph into a list and exposes the raw
267
  # marker for a frame. Keep every completed block visible, but withhold this
268
  # ambiguous final line until its first content character arrives.
269
  defp drop_incomplete_block_marker(text) do
270
    lines = String.split(text, "\n")
271
    tail = List.last(lines)
272
273
    if Regex.match?(@incomplete_block_marker, tail) do
274
      lines
275
      |> Enum.drop(-1)
276
      |> Enum.join("\n")
277
      |> then(&if(length(lines) > 1, do: &1 <> "\n", else: &1))
278
    else
279
      text
280
    end
281
  end
244 282
245 283
  defp close_inline_code(text) do
246 284
    if text |> strip_escapes() |> count_solo_backticks() |> rem(2) == 1 do
test/openagents/markdown_test.exs modified +24

@@ -43,6 +43,14 @@ defmodule OpenAgents.MarkdownTest do

43 43
      assert html("~~gone~~") =~ "<del>"
44 44
    end
45 45
46
    test "renders ordered lists as ordered-list markup" do
47
      rendered = html("1. First\n2. Second")
48
49
      assert rendered =~ "<ol>"
50
      assert rendered =~ "<li>First</li>"
51
      assert rendered =~ "<li>Second</li>"
52
    end
53
46 54
    test "renders tables and malformed Markdown without crashing" do
47 55
      table = html("| A | B |\n|---|---|\n| 1 | 2 |")
48 56

@@ -203,6 +211,17 @@ defmodule OpenAgents.MarkdownTest do

203 211
      assert Markdown.complete("text *") == "text "
204 212
    end
205 213
214
    test "withholds an ambiguous trailing block marker until content arrives" do
215
      for marker <- ["1", "1.", "1. ", "-", "- ", "##", "## ", ">", "> ", "|"] do
216
        assert Markdown.complete(marker) == "",
217
               "rendered incomplete block marker #{inspect(marker)}"
218
      end
219
220
      assert Markdown.complete("Done\n2.") == "Done\n"
221
      assert Markdown.complete("1. First") == "1. First"
222
      assert Markdown.complete("## Heading") == "## Heading"
223
    end
224
206 225
    test "ignores escaped markers" do
207 226
      assert Markdown.complete("2 \\* 3") == "2 \\* 3"
208 227
    end

@@ -215,6 +234,11 @@ defmodule OpenAgents.MarkdownTest do

215 234
      assert completed =~ "the docs"
216 235
    end
217 236
237
    test "a half-arrived link label and HTML tag do not expose their delimiters" do
238
      assert Markdown.complete("see [the doc") == "see the doc"
239
      assert Markdown.complete("before <sect") == "before "
240
    end
241
218 242
    test "a half-arrived image is dropped rather than rendered broken" do
219 243
      assert Markdown.complete("look ![alt](https://exam") == "look "
220 244
    end

This page updates live while a promote is in flight · changelog