diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 0a13f7854..36689718a 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -160,6 +160,7 @@ dependencies { implementation("io.coil-kt:coil-compose:2.7.0") implementation("io.noties.markwon:core:4.6.2") implementation("io.noties.markwon:ext-latex:4.6.2") + implementation("io.noties.markwon:ext-tables:4.6.2") implementation("io.noties.markwon:inline-parser:4.6.2") implementation("io.noties.markwon:syntax-highlight:4.6.2") { exclude(group = "org.jetbrains", module = "annotations-java5") diff --git a/apps/android/app/src/main/java/com/litter/android/ui/conversation/ConversationScreen.kt b/apps/android/app/src/main/java/com/litter/android/ui/conversation/ConversationScreen.kt index fa81058a6..97c9cc74f 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/conversation/ConversationScreen.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/conversation/ConversationScreen.kt @@ -111,6 +111,7 @@ fun ConversationScreen( val prism4j = io.noties.prism4j.Prism4j(com.litter.android.ui.Prism4jGrammarLocator()) io.noties.markwon.Markwon.builder(context) .usePlugin(io.noties.markwon.syntax.SyntaxHighlightPlugin.create(prism4j, io.noties.markwon.syntax.Prism4jThemeDarkula.create())) + .usePlugin(io.noties.markwon.ext.tables.TablePlugin.create(context)) .build() } catch (_: Exception) { io.noties.markwon.Markwon.create(context) @@ -383,11 +384,11 @@ fun ConversationScreen( LaunchedEffect(threadKey, displayedTurnCount, transcriptTailSignature, followScrollToken, streamingRenderTick) { if (shouldFollowTail && displayedTurns.isNotEmpty()) { val bottomAnchorIndex = conversationBottomAnchorIndex(displayedTurnCount) - if (hasPositionedInitialTail) { - listState.animateScrollToItem(bottomAnchorIndex) - } else { + if (!hasPositionedInitialTail || isThinking) { listState.scrollToItem(bottomAnchorIndex) hasPositionedInitialTail = true + } else { + listState.animateScrollToItem(bottomAnchorIndex) } } } diff --git a/apps/android/app/src/main/java/com/litter/android/ui/conversation/ConversationTimeline.kt b/apps/android/app/src/main/java/com/litter/android/ui/conversation/ConversationTimeline.kt index 6a32de272..088c68586 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/conversation/ConversationTimeline.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/conversation/ConversationTimeline.kt @@ -430,7 +430,6 @@ private fun AssistantMessageRow( if (data.text == renderedText) return@LaunchedEffect if (renderedText.isEmpty()) { renderedText = data.text - onStreamingSnapshotRendered?.invoke() } else { pendingText = data.text } @@ -442,7 +441,6 @@ private fun AssistantMessageRow( delay(60) renderedText = nextText pendingText = null - onStreamingSnapshotRendered?.invoke() } Column( diff --git a/apps/android/app/src/main/java/com/litter/android/ui/conversation/SelectableConversationText.kt b/apps/android/app/src/main/java/com/litter/android/ui/conversation/SelectableConversationText.kt index e45f338da..2ef7683cb 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/conversation/SelectableConversationText.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/conversation/SelectableConversationText.kt @@ -22,6 +22,7 @@ import io.noties.markwon.AbstractMarkwonPlugin import io.noties.markwon.Markwon import io.noties.markwon.core.MarkwonTheme import io.noties.markwon.ext.latex.JLatexMathPlugin +import io.noties.markwon.ext.tables.TablePlugin import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin import io.noties.markwon.syntax.SyntaxHighlightPlugin import io.noties.prism4j.Prism4j @@ -45,6 +46,7 @@ internal fun SelectableMarkdownText( modifier: Modifier = Modifier, bodySize: Float = LitterTextStyle.body, usePhysicalDpTextSize: Boolean = false, + selectable: Boolean = true, onTextViewReady: ((TextView) -> Unit)? = null, ) { val context = LocalContext.current @@ -85,6 +87,7 @@ internal fun SelectableMarkdownText( textSize = resolvedTextSize, typeface = typeface, usePhysicalDpTextSize = usePhysicalDpTextSize, + selectable = selectable, ) onTextViewReady?.invoke(this) } @@ -97,13 +100,30 @@ internal fun SelectableMarkdownText( textSize = resolvedTextSize, typeface = typeface, usePhysicalDpTextSize = usePhysicalDpTextSize, + selectable = selectable, ) - markwon.setMarkdown(tv, markdown) + val renderTag = MarkdownRenderTag( + markdown = markdown, + textColor = textColor, + textSizePx = markdownTextSizePx, + typeface = typeface, + ) + if (tv.tag != renderTag) { + tv.tag = renderTag + markwon.setMarkdown(tv, markdown) + } }, modifier = modifier, ) } +private data class MarkdownRenderTag( + val markdown: String, + val textColor: Int, + val textSizePx: Float, + val typeface: android.graphics.Typeface?, +) + internal fun configureSelectableMarkdownTextView( textView: TextView, textColor: Int, @@ -111,6 +131,7 @@ internal fun configureSelectableMarkdownTextView( textSize: Float, typeface: android.graphics.Typeface? = null, usePhysicalDpTextSize: Boolean = false, + selectable: Boolean = true, ) { textView.setTextColor(textColor) textView.typeface = typeface @@ -123,8 +144,12 @@ internal fun configureSelectableMarkdownTextView( textView.linksClickable = true textView.movementMethod = LinkMovementMethod.getInstance() textView.setLinkTextColor(linkColor) - textView.setTextIsSelectable(true) - textView.customSelectionActionModeCallback = RunInTerminalSelectionMenu(textView) + textView.setTextIsSelectable(selectable) + textView.customSelectionActionModeCallback = if (selectable) { + RunInTerminalSelectionMenu(textView) + } else { + null + } } /** @@ -206,6 +231,7 @@ private fun rememberConversationMarkwon( io.noties.markwon.syntax.Prism4jThemeDarkula.create(), ), ) + .usePlugin(TablePlugin.create(context)) .usePlugin(MarkwonInlineParserPlugin.create()) .usePlugin( JLatexMathPlugin.create(markdownTextSizePx, markdownTextSizePx * 1.12f) { builder -> diff --git a/apps/android/app/src/main/java/com/litter/android/ui/conversation/StreamingMarkdownView.kt b/apps/android/app/src/main/java/com/litter/android/ui/conversation/StreamingMarkdownView.kt index 16558c26e..332ab4ce1 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/conversation/StreamingMarkdownView.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/conversation/StreamingMarkdownView.kt @@ -1,7 +1,5 @@ package com.litter.android.ui.conversation -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement @@ -17,7 +15,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext @@ -33,9 +30,10 @@ import com.litter.android.ui.scaled import uniffi.codex_mobile_client.AppMessageRenderBlock /** - * Composable that renders streaming assistant messages with a fade-in reveal - * effect on newly appended tokens. Uses [StreamingTextCoordinator] to split - * text into a stable cached prefix and an animated frontier. + * Composable that renders streaming assistant messages. Uses + * [StreamingTextCoordinator] to split text into a stable cached prefix and a + * small frontier without repeatedly fading the active markdown block; token + * streams can update faster than a fade can complete, which reads as flicker. */ @Composable fun StreamingMarkdownView( @@ -55,12 +53,7 @@ fun StreamingMarkdownView( ) } - // Animate frontier alpha: snap to 0 on new text, then animate to 1 - val frontierAlpha = remember(itemId) { Animatable(1f) } - LaunchedEffect(text) { - frontierAlpha.snapTo(0f) - frontierAlpha.animateTo(1f, animationSpec = tween(durationMillis = 150)) onRendered?.invoke() } @@ -72,16 +65,16 @@ fun StreamingMarkdownView( if (streamState.stableBlocks.isNotEmpty()) { StreamingRenderBlocks( blocks = streamState.stableBlocks, - alpha = 1f, bodySize = bodySize, ) } - // Render frontier blocks with fade-in + // Render frontier blocks at full opacity. Re-parsing the frontier is + // enough motion during streaming; restarting alpha on every token is + // the visible flicker. if (streamState.frontierBlocks.isNotEmpty()) { StreamingRenderBlocks( blocks = streamState.frontierBlocks, - alpha = frontierAlpha.value, bodySize = bodySize, ) } @@ -91,16 +84,14 @@ fun StreamingMarkdownView( @Composable private fun StreamingRenderBlocks( blocks: List, - alpha: Float, bodySize: Float, ) { - blocks.forEachIndexed { index, block -> + blocks.forEach { block -> when (block) { is AppMessageRenderBlock.Markdown -> { if (block.markdown.isNotEmpty()) { StreamingMarkdownText( text = block.markdown, - modifier = Modifier.alpha(alpha), bodySize = bodySize, ) } @@ -109,14 +100,12 @@ private fun StreamingRenderBlocks( if (isMathLanguage(block.language)) { StreamingMarkdownText( text = mathMarkdownBlock(block.code), - modifier = Modifier.alpha(alpha), bodySize = bodySize, ) } else { StreamingCodeBlock( language = block.language, code = block.code, - modifier = Modifier.alpha(alpha), bodySize = bodySize, ) } @@ -130,7 +119,6 @@ private fun StreamingRenderBlocks( .build(), contentDescription = "Assistant image", modifier = Modifier - .alpha(alpha) .fillMaxWidth() .heightIn(max = 300.dp) .clip(RoundedCornerShape(10.dp)), @@ -151,6 +139,7 @@ private fun StreamingMarkdownText( modifier = modifier.fillMaxWidth(), bodySize = bodySize, usePhysicalDpTextSize = true, + selectable = false, ) }