Chat bubbles: render markdown (bold/italic/tables/syntax-highlighted code) for agent + user messages; tap-to-copy inline code with flash, code-block copy button; preserve user newlines; removed stale HANDOFF doc

This commit is contained in:
ARIA committed 2026-08-20 23:29:43 +02:00
1 parent a8920d8d26
commit f8effb4152
6 files changed
+330 -177

No files matched your search

+16
View File
@@ -10,6 +10,10 @@ val composeVersion = "1.7.3"
val coroutinesVersion = "1.9.0"
val serializationVersion = "1.7.3"
val okhttpVersion = "4.12.0"
// Pinned to 0.33.0: the last release built against Compose 1.7.x / Kotlin 2.1.x
// (0.34.0+ moves to Compose 1.8+). Provides GFM tables, bold/italic/underscore,
// and the -code module for language-aware syntax highlighting (Highlights).
val markdownVersion = "0.33.0"
kotlin {
androidTarget()
@@ -27,11 +31,23 @@ kotlin {
implementation("org.jetbrains.compose.foundation:foundation:$composeVersion")
implementation("org.jetbrains.compose.material3:material3:$composeVersion")
implementation("org.jetbrains.compose.ui:ui:$composeVersion")
// Full Material icon set (Icons.Filled.ContentCopy for the code
// copy buttons); -core only ships a small subset.
implementation("org.jetbrains.compose.material:material-icons-extended:$composeVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion")
// OkHttp is a JVM library; both targets here are JVM-based
// (androidTarget + jvm("desktop")), so it resolves on both.
implementation("com.squareup.okhttp3:okhttp:$okhttpVersion")
// Markdown rendering for chat bubbles (M8): core parser/renderer,
// Material 3 themed defaults, and the -code module for syntax
// highlighting of fenced code blocks (Highlights).
implementation("com.mikepenz:multiplatform-markdown-renderer:$markdownVersion")
implementation("com.mikepenz:multiplatform-markdown-renderer-m3:$markdownVersion")
implementation("com.mikepenz:multiplatform-markdown-renderer-code:$markdownVersion")
}
commonTest.dependencies {
implementation(kotlin("test"))
}
// M4: ExoPlayer (Media3) for inline audio/video playback (Android only).
androidMain.dependencies {
@@ -0,0 +1,216 @@
package iris.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.LinkInteractionListener
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.compose.components.markdownComponents
import com.mikepenz.markdown.compose.elements.MarkdownCodeBlock
import com.mikepenz.markdown.compose.elements.MarkdownCodeFence
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCode
import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.markdownAnnotator
import com.mikepenz.markdown.model.rememberMarkdownState
import com.mikepenz.markdown.utils.getUnescapedTextInNode
import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.SyntaxThemes
import iris.ui.theme.IrisColors
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.intellij.markdown.MarkdownElementTypes
/**
* Renders a chat message as markdown (M8): bold / italic / underscore, GFM
* tables, task-list checkboxes, and fenced code blocks with language-aware
* syntax highlighting (```json → JSON, ```kotlin → Kotlin, …). [text] is the
* raw markdown; [color] and [fontSize] match the surrounding bubble so the
* rendered text blends in.
*/
@Composable
fun MarkdownText(
text: String,
color: Color,
fontSize: TextUnit,
modifier: Modifier = Modifier,
) {
val state = rememberMarkdownState(text)
// The app is always dark-themed, so force the dark highlight palette
// (isSystemInDarkTheme() is unreliable on desktop).
val highlights = remember {
Highlights.Builder().theme(SyntaxThemes.default(darkMode = true))
}
val base = TextStyle(color = color, fontSize = fontSize)
val inlineCodeBackground = Color.White.copy(alpha = 0.08f)
// Inline-code span style (mirrors the library's codeSpanStyle): the
// monospace inlineCode typography plus the chip background.
val codeSpanStyle =
base.copy(fontFamily = FontFamily.Monospace, background = inlineCodeBackground).toSpanStyle()
// Brief green flash shown on the chip right after a copy, so the tap is
// visible feedback (the clipboard write itself is silent).
val copiedCodeSpanStyle =
base.copy(fontFamily = FontFamily.Monospace, background = IrisColors.statusGreen.copy(alpha = 0.30f))
.toSpanStyle()
val clipboard = LocalClipboardManager.current
val scope = rememberCoroutineScope()
// The text of the inline code most recently copied (null when idle). Read
// inside the annotator so a change re-renders the chip with the flash.
val copiedCode = remember { mutableStateOf<String?>(null) }
// The default CODE_SPAN handler pads inline code with a leading and a
// trailing space, which doubled the whitespace around `code` mid-line.
// Re-render it without the padding, and wrap it in a link so tapping the
// inline code copies it to the clipboard (Telegram-style). The
// LinkAnnotation carries the click listener; the URL is never opened.
val annotator = markdownAnnotator(
annotate = { content, child ->
if (child.type == MarkdownElementTypes.CODE_SPAN) {
val children = child.children
// Drop the surrounding backtick tokens (present as first/last child).
val inner = if (children.size >= 3) children.subList(1, children.size - 1) else children
val code = inner.joinToString("") { it.getUnescapedTextInNode(content) }
val spanStyle = if (code == copiedCode.value) copiedCodeSpanStyle else codeSpanStyle
pushStyle(spanStyle)
withLink(
LinkAnnotation.Url(
url = "iris:copy-code",
// Keep every interaction state identical to the chip so
// hover/press never restyles the inline code.
styles = TextLinkStyles(
style = spanStyle,
focusedStyle = spanStyle,
hoveredStyle = spanStyle,
pressedStyle = spanStyle,
),
linkInteractionListener = LinkInteractionListener {
clipboard.setText(AnnotatedString(code))
copiedCode.value = code
scope.launch {
delay(600)
copiedCode.value = null
}
},
),
) {
append(code)
}
pop()
true
} else {
false
}
},
)
Markdown(
markdownState = state,
modifier = modifier,
annotator = annotator,
colors = markdownColor(
text = color,
codeBackground = Color.Black.copy(alpha = 0.8f),
inlineCodeBackground = inlineCodeBackground,
dividerColor = IrisColors.divider,
tableBackground = Color.White.copy(alpha = 0.03f),
),
typography = markdownTypography(
h1 = base.copy(fontSize = fontSize * 1.3f, fontWeight = FontWeight.Bold),
h2 = base.copy(fontSize = fontSize * 1.2f, fontWeight = FontWeight.Bold),
h3 = base.copy(fontSize = fontSize * 1.1f, fontWeight = FontWeight.Bold),
h4 = base.copy(fontWeight = FontWeight.Bold),
h5 = base.copy(fontWeight = FontWeight.Bold),
h6 = base.copy(fontWeight = FontWeight.Bold),
text = base,
code = base.copy(fontSize = fontSize * 0.9f, fontFamily = FontFamily.Monospace),
inlineCode = base.copy(fontFamily = FontFamily.Monospace),
quote = base.copy(fontStyle = FontStyle.Italic),
paragraph = base,
ordered = base,
bullet = base,
list = base,
link = base.copy(textDecoration = TextDecoration.Underline),
table = base.copy(fontSize = fontSize * 0.95f),
),
components = markdownComponents(
codeFence = { model ->
MarkdownCodeFence(model.content, model.node, model.typography.code) { code, language, style ->
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
}
},
codeBlock = { model ->
MarkdownCodeBlock(model.content, model.node, model.typography.code) { code, language, style ->
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
}
},
// The core default checkbox renders literal "[x]"/"[ ]" text; use the
// Material 3 checkbox instead.
checkbox = {
com.mikepenz.markdown.m3.elements.MarkdownCheckBox(it.content, it.node, it.typography.text)
},
),
loading = { m ->
// While (re)parsing — e.g. on each streaming update — show the raw
// text so the bubble never goes blank between updates.
Text(text, modifier = m, color = color, fontSize = fontSize)
},
)
}
/**
* Renders a highlighted code block with a copy button in the top-right corner
* (Telegram-style). Tapping the button copies the whole block to the clipboard.
*/
@Composable
private fun CodeBlockWithCopy(
code: String,
language: String?,
style: TextStyle,
highlights: Highlights.Builder,
) {
val clipboard = LocalClipboardManager.current
Box {
MarkdownHighlightedCode(code = code, language = language, highlights = highlights, style = style)
if (code.isNotBlank()) {
IconButton(
onClick = { clipboard.setText(AnnotatedString(code)) },
modifier = Modifier
.align(Alignment.TopEnd)
// MarkdownHighlightedCode insets its background by 8dp top;
// match that so the button sits inside the block, not above it.
.padding(top = 8.dp, end = 4.dp)
.size(28.dp),
) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = "Copy code",
tint = Color.White.copy(alpha = 0.6f),
)
}
}
}
}
@@ -123,6 +123,7 @@ import iris.protocol.ROLE_USER
import iris.protocol.SearchHit
import iris.state.IrisController
import iris.state.ToolDetail
import iris.ui.MarkdownText
import iris.ui.rightClick
import iris.ui.theme.IrisColors
import iris.ui.theme.LocalUserTheme
@@ -131,6 +132,8 @@ import iris.ui.theme.contrastText
import iris.util.formatDayLabel
import iris.util.formatTime
import iris.util.localDayKey
import iris.util.prepareForMarkdown
import iris.util.preserveNewlinesAsHardBreaks
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -1910,11 +1913,14 @@ private fun MessageBubble(
}
if (isUser) {
// M7: text + inline timestamp / delivery status at the end
// (reference look); the bubble hugs its content.
// (reference look); the bubble hugs its content. User text is
// rendered as markdown too (bold / italic / inline code), so a
// pasted snippet or emphasis shows up the same as in replies.
Row(verticalAlignment = Alignment.Bottom) {
if (msg.text.isNotBlank() || msg.streaming) {
Text(
msg.text + if (msg.streaming) " ▉" else "",
MarkdownText(
text = msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() +
if (msg.streaming) " ▉" else "",
color = textColor,
fontSize = 15.sp,
)
@@ -1945,10 +1951,16 @@ private fun MessageBubble(
}
} else {
if (msg.text.isNotBlank() || msg.streaming) {
Text(
msg.text + if (msg.streaming) " ▉" else "",
// M8: render the agent's reply as markdown (bold / italic /
// underscore, tables, highlighted code blocks). Leading
// newlines are stripped so the text hugs the top of the
// bubble; the ▉ cursor is kept while streaming.
val displayText = msg.text.prepareForMarkdown() + if (msg.streaming) " ▉" else ""
MarkdownText(
text = displayText,
color = textColor,
fontSize = if (isCommentary) 13.sp else 15.sp,
modifier = Modifier.fillMaxWidth(),
)
}
}
@@ -0,0 +1,39 @@
package iris.util
/**
* Drops the leading newlines / blank lines a model reply often starts with, so
* the rendered text hugs the top of the bubble instead of leaving a blank gap.
* Leading indentation on the first real line is preserved, so an indented code
* block at the very start of a message still parses as code.
*/
fun String.prepareForMarkdown(): String {
val lines = split("\n")
var i = 0
while (i < lines.size && lines[i].isBlank()) i++
return lines.drop(i).joinToString("\n")
}
/**
* Converts the single newlines a user typed into markdown hard breaks (two
* trailing spaces) so they render as line breaks instead of being collapsed
* into spaces by the parser (GFM treats a lone newline inside a paragraph as a
* space). Lines inside fenced code blocks (``` / ~~~) are left untouched so the
* code content is not altered; blank lines are kept as-is since they already
* produce paragraph breaks.
*/
fun String.preserveNewlinesAsHardBreaks(): String {
val lines = split("\n")
var inCodeBlock = false
val out = lines.map { line ->
val trimmed = line.trimStart()
when {
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
inCodeBlock = !inCodeBlock
line
}
inCodeBlock || line.isBlank() -> line
else -> line + " "
}
}
return out.joinToString("\n")
}
@@ -0,0 +1,42 @@
package iris.util
import kotlin.test.Test
import kotlin.test.assertEquals
class MarkdownTest {
@Test
fun stripsLeadingNewlines() {
assertEquals("Hello", "\n\nHello".prepareForMarkdown())
assertEquals("Hello", "\nHello".prepareForMarkdown())
assertEquals("Hello", "\r\n\r\nHello".prepareForMarkdown())
}
@Test
fun stripsLeadingBlankLines() {
assertEquals("Hello", "\n \n\t\nHello".prepareForMarkdown())
assertEquals("a\nb", "\n\na\nb".prepareForMarkdown())
}
@Test
fun preservesLeadingIndentationOnFirstLine() {
// An indented code block at the very start must still parse as code.
assertEquals(" code", "\n\n code".prepareForMarkdown())
}
@Test
fun leavesInteriorWhitespaceUntouched() {
val md = "Line one\n\nLine two\n\n- item"
assertEquals(md, md.prepareForMarkdown())
}
@Test
fun noLeadingNewlinesIsUnchanged() {
assertEquals("Hello world", "Hello world".prepareForMarkdown())
}
@Test
fun allBlankBecomesEmpty() {
assertEquals("", "\n\n \n".prepareForMarkdown())
}
}