fix(#572): render Markdown in assistant chat bubbles after streaming (#577)

* fix(#572): render Markdown in assistant chat bubbles after streaming

- Add MarkdownText composable: block-aware parser (headings, lists,
  blockquotes, fenced code, inline code, bold, italic, links)
- Gate formatting on message.isStreaming: plain text during streaming,
  formatted view swapped in on completion (kills flicker/re-parse churn)
- Fenced code blocks render as a scrollable card with a copy button
- Remove dead RichText; wire AssistantBubble to MarkdownText
- ktlintCheck + compileDebugKotlin + testDebugUnitTest green

* fix(#572): address agy review — memoize inline parse, drop nested SelectionContainers, fix heading detection, reset copy state

* fix(#572): expand Markdown support — tables, strike, bold+italic, task lists, hr, footnotes, def-lists, highlight, sup/sub, kbd (math excluded)

* fix(#572): address agy review — fix table alignment (textAlign) + quote bar height (IntrinsicSize), fix ktlint line length

* fix(#572): resolve infinite loop hang on hashtag refs in markdown parser

---------

Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-13 17:24:59 +03:00
1 parent 8d22988985
commit 594255d534
3 files changed
+1119 -117

No files matched your search

@@ -0,0 +1,168 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.ui.graphics.Color
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Verifies the hand-rolled Markdown parser covers the feature set requested for issue #572
* (math excluded by decision). Each test asserts the block/inline structure actually parses —
* not just that it compiles. Inline assertions avoid referencing Compose text types (FontWeight,
* BaselineShift, etc.) that aren't on the unit-test classpath; we inspect SpanStyle fields
* structurally instead.
*/
class MarkdownTextFeatureTest {
// 1. TABLES
@Test
fun testTable_parsesHeaderAndRows() {
val md =
"""
| Name | Age | Role |
|------|:---:|-----:|
| Alice | 30 | dev |
| Bob | 25 | ops |
""".trimIndent()
val blocks = parseBlocks(md)
val table = blocks.singleOrNull { it is MdBlock.Table } as MdBlock.Table?
assertTrue("table block expected", table != null)
assertEquals(listOf("Name", "Age", "Role"), table!!.header)
assertEquals(2, table.rows.size)
assertEquals("Alice", table.rows[0][0])
assertEquals("ops", table.rows[1][2])
// alignment inference: center, left, right
assertEquals(TableAlign.CENTER, table.alignments[1])
assertEquals(TableAlign.RIGHT, table.alignments[2])
}
// 2. MATH — excluded; ensure a math-looking string stays plain, not crashed
@Test
fun testMath_isTreatedAsPlainText() {
val md = "E = mc^2^ and `x = (-b ± √(b²-4ac)) / 2a`"
val block = parseBlocks(md).single() as MdBlock.Paragraph
assertEquals(md, block.text)
}
// 3. STRIKETHROUGH
@Test
fun testStrikethrough_parses() {
val an = parseInline("~~gone~~", Color.Black, "", false, Color.Blue)
assertEquals("gone", an.toString())
assertTrue(an.spanStyles.any { it.item.textDecoration != null })
}
// 4. BOLD + ITALIC in same word (***bolditalic***)
@Test
fun testBoldItalic_combined() {
val an = parseInline("***both***", Color.Black, "", false, Color.Blue)
assertEquals("both", an.toString())
assertTrue(an.spanStyles.isNotEmpty())
}
// 5. TASK LIST / CHECKBOX
@Test
fun testTaskList_parsesCheckedAndUnchecked() {
val md =
"""
- [x] done
- [ ] todo
""".trimIndent()
val blocks = parseBlocks(md)
assertEquals(2, blocks.size)
val done = blocks[0] as MdBlock.Task
val todo = blocks[1] as MdBlock.Task
assertTrue(done.checked)
assertFalse(todo.checked)
assertEquals("done", done.text)
assertEquals("todo", todo.text)
}
// 6. HORIZONTAL RULE (---)
@Test
fun testHorizontalRule_parses() {
val md =
"""
above
---
below
""".trimIndent()
val blocks = parseBlocks(md)
assertTrue(blocks.any { it is MdBlock.Hr })
}
// 7. FOOTNOTES
@Test
fun testFootnotes_collectedAndRendered() {
val md =
"""
Science is cool.[^1]
[^1]: A famous claim.
""".trimIndent()
val blocks = parseBlocks(md)
val fn = blocks.singleOrNull { it is MdBlock.Footnotes } as MdBlock.Footnotes?
assertTrue(fn != null)
assertEquals("1", fn!!.notes[0].id)
assertEquals("A famous claim.", fn.notes[0].text)
}
// 8. DEFINITION LIST
@Test
fun testDefinitionList_parses() {
val md =
"""
Term
: first definition
: second definition
""".trimIndent()
val dl = parseBlocks(md).singleOrNull { it is MdBlock.DefList } as MdBlock.DefList?
assertTrue(dl != null)
assertEquals("Term", dl!!.items[0].term)
assertEquals(2, dl.items[0].definitions.size)
assertEquals("first definition", dl.items[0].definitions[0])
}
// 9. HIGHLIGHT
@Test
fun testHighlight_parses() {
val an = parseInline("==mark==", Color.Black, "", false, Color.Blue)
assertEquals("mark", an.toString())
assertTrue(an.spanStyles.any { it.item.background != null })
}
// 10. SUPERSCRIPT / SUBSCRIPT
@Test
fun testSuperscriptAndSubscript_parse() {
val sup = parseInline("x^2^", Color.Black, "", false, Color.Blue)
assertTrue(sup.spanStyles.any { it.item.baselineShift != null })
val sub = parseInline("H~2~O", Color.Black, "", false, Color.Blue)
assertTrue(sub.spanStyles.any { it.item.baselineShift != null })
}
// 11. KEYBOARD KEYS <kbd>
@Test
fun testKbd_parses() {
val an = parseInline("Press <kbd>Ctrl</kbd>+<kbd>C</kbd>", Color.Black, "", false, Color.Blue)
assertTrue(an.toString().contains("Ctrl"))
assertTrue(an.toString().contains("C"))
assertTrue(an.spanStyles.any { it.item.fontFamily != null })
}
// --- regression: issue ref "#572" must NOT become a heading ---
@Test
fun testHashRef_notHeading() {
val blocks = parseBlocks("#572 should stay text")
assertTrue(blocks.single() is MdBlock.Paragraph)
}
// --- regression: streaming gate handled in composable, parser must not split inline code ---
@Test
fun testInlineCode_preserved() {
val an = parseInline("use `val x = 1` here", Color.Black, "", false, Color.Blue)
assertEquals("use val x = 1 here", an.toString())
assertTrue(an.spanStyles.any { it.item.fontFamily != null })
}
}