feat(chat): render images + attachments in assistant bubbles (#720)

Assistant messages could not display inline images or attachments on the
mobile client. The markdown renderer had no image block, and
AssistantBubble only rendered MarkdownText + timestamp, ignoring
message.attachments (only UserBubble did).

- Add MdBlock.Image + standalone ![alt](uri) parsing in MarkdownText.
  Accepts http(s) gateway URLs and data:image/...;base64 inline media.
- Render message.attachments inside AssistantBubble (mirrors UserBubble)
  via InlineAttachment / AsyncImage.
- Add parser unit tests for image blocks (url, data: URL, inline-in-paragraph).

Local verification: ./gradlew ktlintCheck compileDebugKotlin assembleDebug
all green; app-debug.apk produced.

Fixes feature gap where agent-delivered media (e.g. MEDIA:/ images
sent to chat) was invisible in the Android app.

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-26 17:48:03 +03:00
1 parent 0180fb12c2
commit 50fdc12992
3 files changed
+92

No files matched your search

@@ -406,6 +406,18 @@ private fun AssistantBubble(
isCurrentMatch = isCurrentMatch,
)
}
// Render inline attachments (mirrors UserBubble so agent-delivered
// media — images, files — shows in assistant bubbles too).
if (!message.attachments.isNullOrEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
message.attachments.forEach { attachment ->
InlineAttachment(
attachment = attachment,
textColor = textColor,
)
Spacer(modifier = Modifier.height(4.dp))
}
}
if (!message.isStreaming) {
Text(
text = formatTimestamp(message.timestamp, DateFormat.is24HourFormat(LocalContext.current)),
@@ -264,6 +264,30 @@ fun MarkdownText(
}
}
is MdBlock.Image -> {
// AsyncImage is a Compose runtime composable — load it lazily
// behind remember so the parser stays pure and testable (the
// parseBlocks/parseInline unit tests never touch Compose).
val model: Any = block.uri
androidx.compose.foundation.layout.Box(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.padding(vertical = 4.dp),
) {
coil.compose.AsyncImage(
model = model,
contentDescription = block.alt.ifBlank { null },
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp)),
contentScale = androidx.compose.ui.layout.ContentScale.FillWidth,
)
}
}
is MdBlock.DefList -> {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
block.items.forEach { item ->
@@ -345,6 +369,21 @@ fun MarkdownText(
}
}
/**
* Matches a Markdown image: `![alt](uri)`. The `uri` may be an http(s) URL
* (gateway-served media the phone can reach), a `data:image/...;base64,...`
* data URL (e.g. agent-delivered inline media), or any other resolvable model
* string (Coil handles all three). Bare `![]()` with empty uri is skipped.
*/
private val IMAGE_RE = Regex("""^!\[([^\]]*)\]\(([^)\s]+)\s*\)""")
private fun tryParseImage(line: String): MdBlock.Image? {
val m = IMAGE_RE.matchAt(line, 0) ?: return null
val uri = m.groupValues[2].trim()
if (uri.isEmpty()) return null
return MdBlock.Image(uri = uri, alt = m.groupValues[1].trim())
}
@Composable
private fun MarkdownTable(
block: MdBlock.Table,
@@ -514,6 +553,14 @@ internal fun parseBlocks(src: String): List<MdBlock> {
items.forEachIndexed { idx, t -> blocks.add(MdBlock.Ordered(idx + 1, t)) }
}
// Standalone Markdown image: ![alt](uri) on its own line.
// Inline images inside a paragraph are left as-is (rendered as text)
// to keep scope tight; standalone is the common agent-media case.
line.isNotBlank() && tryParseImage(line) != null -> {
blocks.add(tryParseImage(line)!!)
i++
}
else -> {
i = fallthroughToParagraph(lines, i, blocks)
}
@@ -903,6 +950,11 @@ internal sealed interface MdBlock {
val text: String,
) : MdBlock
data class Image(
val uri: String,
val alt: String = "",
) : MdBlock
data class Quote(
val text: String,
) : MdBlock