Render markdown, and stop wiping messages still waiting to be read

Two things, both about the transcript telling the truth about itself.

Markdown is rendered rather than shown as its source. The parsing is
mikepenz/multiplatform-markdown-renderer, not something written here:
markdown is somebody else's specification, and a hand-written subset of
one disagrees with it at the edges, which is where the bug reports come
from. `Markdown.kt` is only the mapping onto this app's palette, so code,
links and rules take the Catppuccin values the rest of the app uses
rather than the renderer's defaults.

The queued-message list was cleared wholesale whenever a turn ended. But
the backend holds a queue of its own and takes one message per turn, so a
turn ending is precisely the moment the *rest* are still waiting -- the
bubbles vanished while the messages were on their way, which reads as
everything after the first having been dropped. Now a held message
leaves the list exactly two ways: the session reads it, which arrives as
a UserMessage, or its send failed and there is nothing to wait for.

Measured first, because the report was that the backend dropped them:
three messages sent behind one long turn were all delivered in order
(ONE, TWO, THREE) against current main, so the loss was in the display.

Verified on the emulator: headings, emphasis, inline code, nested lists,
a quote bar, a fenced block, a rule and a link all render, and the three
queued messages sit through their turn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-29 06:10:22 -04:00
1 parent 90a57ca7e9
commit 9f403cddab
10 files changed
+398 -94

No files matched your search

+1
View File
@@ -144,4 +144,5 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.zxing.embedded)
implementation(libs.markdown.renderer)
}
@@ -0,0 +1,44 @@
package com.example.aiapp
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography
/**
* An assistant's reply, rendered as the markdown it is written in.
*
* The parsing is the library's. Markdown is somebody else's specification, and a hand-written
* subset of one disagrees with it at the edges -- which is where the bug reports come from, one
* case at a time. This file's whole job is the mapping onto the app's palette.
*
* Colours come from the theme rather than from the renderer's defaults, so code, links and rules
* are the same Catppuccin values the rest of the app uses. Nothing here picks a colour of its own.
*/
@Composable
fun MarkdownText(text: String, modifier: Modifier = Modifier) {
Markdown(
content = text,
colors =
markdownColor(
text = MaterialTheme.colorScheme.onSurface,
codeText = codeColor,
inlineCodeText = codeColor,
linkText = linkColor,
dividerColor = MaterialTheme.colorScheme.outlineVariant,
codeBackground = MaterialTheme.colorScheme.surfaceVariant,
inlineCodeBackground = MaterialTheme.colorScheme.surfaceVariant,
),
// Body text at the size everything else in the transcript uses, and code in a monospace
// face: a code block set in the body font stops looking like code at all.
typography =
markdownTypography(
text = MaterialTheme.typography.bodyLarge,
code = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
),
modifier = modifier,
)
}
@@ -372,10 +372,6 @@ fun SessionScreen(
}
}
// A send that never came back cannot stay outstanding forever; the
// turn ending is the latest moment it could still have been in flight.
LaunchedEffect(running) { if (!running) queued = emptyList() }
LaunchedEffect(summary.setupName, summary.provider) {
offeredModels =
try {
@@ -394,13 +390,14 @@ fun SessionScreen(
}
}
fun act(action: () -> Unit) {
fun act(onFailure: () -> Unit = {}, action: () -> Unit) {
scope.launch {
try {
withContext(Dispatchers.IO) { action() }
actionError = null
} catch (e: ApiException) {
actionError = e.message
onFailure()
}
}
}
@@ -412,7 +409,17 @@ fun SessionScreen(
input = ""
pendingAttachments = emptyList()
if (running && text.isNotEmpty()) queued = queued + text
act { sendMessage(settings, summary.id, text, attachments) }
// A held message leaves this list exactly two ways: the session
// reads it, which comes back as a UserMessage (see `apply`), or the
// send itself failed and there is nothing to wait for. Clearing the
// whole list when a turn ended was neither -- the server holds a
// queue of its own and takes one message per turn, so ending a turn
// is precisely when the *rest* are still waiting. It wiped them off
// the screen while they were on their way, which reads as messages
// two and three having been dropped.
act(onFailure = { queued = queued - text }) {
sendMessage(settings, summary.id, text, attachments)
}
}
// The system photo picker; the image uploads as soon as it's chosen,
@@ -547,8 +554,7 @@ fun SessionScreen(
items(items.asReversed()) { item ->
when (item) {
is TranscriptItem.UserMsg -> UserBubble(item.text)
is TranscriptItem.AssistantMsg ->
Text(item.text, style = MaterialTheme.typography.bodyLarge)
is TranscriptItem.AssistantMsg -> MarkdownText(item.text)
is TranscriptItem.ToolRun ->
ToolCard(
tool = item,
@@ -115,6 +115,21 @@ val awaitingColor: Color
val warningColor: Color
@Composable get() = Mocha.Yellow
/**
* Code: a fenced block, an inline span, a tool's input.
*
* Green because on this palette it is what a literal is coloured as, and because code sits on
* Surface 0 where the ordinary text colour would say nothing about it being code.
*/
val codeColor: Color
@Composable get() = Mocha.Green
/**
* A link. Blue is what a link is on every Catppuccin surface, and the one colour to leave alone.
*/
val linkColor: Color
@Composable get() = Mocha.Blue
/** Past a limit. The scheme's error colour, for the reason [failedColor] gives. */
val overLimitColor: Color
@Composable get() = MaterialTheme.colorScheme.error
+9
View File
@@ -13,6 +13,12 @@ androidx-activityCompose = "1.13.0"
# .toUri), and a transitive it merely inherited could change under it.
androidx-core-ktx = "1.19.0"
zxing-embedded = "4.3.0"
# Markdown rendering for assistant replies. The widely-used Compose
# Multiplatform renderer; markdown is somebody else's specification and a
# hand-written subset disagrees with it at the edges, one bug report at a
# time. Latest stable, checked 2026-08-29 against Maven Central -- 0.27.0
# exists only as release candidates.
markdown-renderer = "0.26.0"
# Declared rather than inherited for the same reason as core-ktx: SessionScreen
# now calls repeatOnLifecycle/LocalLifecycleOwner directly, to hold the event
# stream open only while the screen is on screen. Latest stable, checked
@@ -38,6 +44,9 @@ androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-ru
# Services / ML Kit model download involved.
zxing-embedded = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxing-embedded" }
desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar-jdk-libs" }
# The -m3 flavour: it takes its colours and type from the ambient Material 3
# theme, so the app's Catppuccin scheme is what it draws with.
markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdown-renderer" }
# Declared directly rather than through the plugin's `compose.*` accessors,
# which are deprecated as of CMP 1.11.
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" }