mmap20: Markdown, Auto-Scroll, Antwortzustände, Typografie, versionCode=4

This commit is contained in:
ODY Build 2026-07-21 08:43:00 +00:00
parent 83b7a33122
commit c4610a3796
2 changed files with 196 additions and 98 deletions

View file

@ -10,8 +10,8 @@ android {
applicationId = "de.ody" applicationId = "de.ody"
minSdk = 28 minSdk = 28
targetSdk = 36 targetSdk = 36
versionCode = 3 versionCode = 4
versionName = "0.3.0" versionName = "0.4.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
signingConfigs { signingConfigs {

View file

@ -23,8 +23,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
@ -38,17 +41,105 @@ import de.ody.runtime.GenerationRequest
import de.ody.runtime.RuntimeState import de.ody.runtime.RuntimeState
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import android.os.Handler
import android.os.Looper
import androidx.compose.runtime.snapshots.Snapshot
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
// ─────────────────────────────────────────────────────────────────────────────
// Antwortzustände
// ─────────────────────────────────────────────────────────────────────────────
enum class MessageState { GENERATING, COMPLETE, ERROR }
data class ChatMessage(
val role: String, // "user" | "assistant"
val content: String,
val state: MessageState = MessageState.COMPLETE,
)
// ─────────────────────────────────────────────────────────────────────────────
// Markdown-Parser — robuster Subset: **fett**, # ## ###, 1. Listen, - Listen
// Kein Crash bei fehlerhafter Eingabe.
// ─────────────────────────────────────────────────────────────────────────────
@Composable
fun MarkdownText(
text: String,
color: Color,
fontSize: androidx.compose.ui.unit.TextUnit = 15.sp,
lineHeight: androidx.compose.ui.unit.TextUnit = 24.sp,
) {
val annotated = buildAnnotatedString {
val lines = text.split("\n")
lines.forEachIndexed { lineIdx, rawLine ->
val line = rawLine.trimEnd()
// Überschriften: # ## ###
val headingMatch = Regex("^(#{1,3})\\s+(.*)").find(line)
if (headingMatch != null) {
val level = headingMatch.groupValues[1].length
val headingText = headingMatch.groupValues[2]
val headingSize = when (level) { 1 -> 1.25f; 2 -> 1.1f; else -> 1.0f }
withStyle(SpanStyle(
fontWeight = FontWeight.Bold,
fontSize = (fontSize.value * headingSize).sp,
)) {
appendInlineBold(headingText)
}
if (lineIdx < lines.lastIndex) append("\n")
return@forEachIndexed
}
// Nummerierte Liste: "1. " "2. " etc.
val numberedMatch = Regex("^(\\d+\\.\\s+)(.*)").find(line)
if (numberedMatch != null) {
withStyle(SpanStyle(fontWeight = FontWeight.Medium)) {
append(numberedMatch.groupValues[1])
}
appendInlineBold(numberedMatch.groupValues[2])
if (lineIdx < lines.lastIndex) append("\n")
return@forEachIndexed
}
// Aufzählung: "- " oder "* "
val bulletMatch = Regex("^[-*]\\s+(.*)").find(line)
if (bulletMatch != null) {
append("")
appendInlineBold(bulletMatch.groupValues[1])
if (lineIdx < lines.lastIndex) append("\n")
return@forEachIndexed
}
// Normaler Text mit **fett**
appendInlineBold(line)
if (lineIdx < lines.lastIndex) append("\n")
}
}
Text(
text = annotated,
color = color,
fontSize = fontSize,
lineHeight = lineHeight,
)
}
// Inline **fett** parsen — nie crashen
private fun androidx.compose.ui.text.AnnotatedString.Builder.appendInlineBold(text: String) {
// Teile den Text an ** auf
val parts = text.split("**")
parts.forEachIndexed { idx, part ->
if (idx % 2 == 1) {
// Ungerade Index = zwischen ** ** = fett
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(part)
}
} else {
append(part)
}
}
}
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// P1+P2: Edge-to-Edge aktivieren — Layout verwaltet eigene Insets
// P8: Edge-to-Edge-Test: Safe Insets werden via systemBarsPadding() + imePadding() gesetzt
enableEdgeToEdge() enableEdgeToEdge()
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
setContent { setContent {
@ -80,7 +171,7 @@ class MainActivity : ComponentActivity() {
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// ODY Chat Screen — kennt ausschließlich AssistantEngine und RuntimeState // ODY Chat Screen
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@Composable @Composable
@ -89,24 +180,19 @@ fun ODYChatScreen() {
val app = context.applicationContext as ODYApplication val app = context.applicationContext as ODYApplication
val engine: AssistantEngine = remember { app.engine } val engine: AssistantEngine = remember { app.engine }
// FIX 1: engine.state → engine.runtimeState
val runtimeState by engine.runtimeState.collectAsState() val runtimeState by engine.runtimeState.collectAsState()
// FIX mmap14: rememberCoroutineScope() wird bei Recompose gecancelt
// → generate() wird abgebrochen → currentOutput bleibt leer
// lifecycleScope der Activity überlebt Recomposes
val activity = LocalContext.current as? androidx.activity.ComponentActivity
// FIX mmap17: stabiler lifecycleScope, kein remember() Fallback
val scope = (LocalContext.current as? androidx.activity.ComponentActivity)?.lifecycleScope val scope = (LocalContext.current as? androidx.activity.ComponentActivity)?.lifecycleScope
?: rememberCoroutineScope() ?: rememberCoroutineScope()
var textInput by remember { mutableStateOf("") } var textInput by remember { mutableStateOf("") }
var messages by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) } var messages by remember { mutableStateOf<List<ChatMessage>>(emptyList()) }
var currentOutput by remember { mutableStateOf("") } var currentOutput by remember { mutableStateOf("") }
var batteryLevel by remember { mutableStateOf(0) } var batteryLevel by remember { mutableStateOf(0) }
var guardMsg by remember { mutableStateOf<GuardAlert?>(null) } var guardMsg by remember { mutableStateOf<GuardAlert?>(null) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
// ODY-Farbpalette // Farben
val bgColor = Color(0xFF0D0D0F) val bgColor = Color(0xFF0D0D0F)
val surfaceColor = Color(0xFF1A1A1E) val surfaceColor = Color(0xFF1A1A1E)
val userBubbleColor = Color(0xFF1E3A5F) val userBubbleColor = Color(0xFF1E3A5F)
@ -117,9 +203,8 @@ fun ODYChatScreen() {
val textSecondary = Color(0xFF8A8A9A) val textSecondary = Color(0xFF8A8A9A)
val guardColor = Color(0xFFCC3333) val guardColor = Color(0xFFCC3333)
val successColor = Color(0xFF2ECC71) val successColor = Color(0xFF2ECC71)
val errorColor = Color(0xFFFF6B6B)
// FIX 2: loadModel()-Aufruf entfernt — Modell-Laden passiert in ODYApplication.onCreate()
// via engine.start(modelFile). Nur batteryLevel-Initialisierung bleibt.
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
batteryLevel = MainActivity.getBatteryLevel(context) batteryLevel = MainActivity.getBatteryLevel(context)
} }
@ -131,13 +216,12 @@ fun ODYChatScreen() {
runtimeState == RuntimeState.VALIDATING -> "Prüfe Modell..." runtimeState == RuntimeState.VALIDATING -> "Prüfe Modell..."
runtimeState == RuntimeState.LOADING -> "Lädt Modell..." runtimeState == RuntimeState.LOADING -> "Lädt Modell..."
runtimeState == RuntimeState.MODEL_LOAD_FAILED -> "Ladefehler — Modell ungültig" runtimeState == RuntimeState.MODEL_LOAD_FAILED -> "Ladefehler — Modell ungültig"
runtimeState == RuntimeState.READY -> "Autark · Lokal aktiv" runtimeState == RuntimeState.READY -> "Autark · Lokal aktiv"
runtimeState == RuntimeState.GENERATING -> "Autark · Generiert" runtimeState == RuntimeState.GENERATING -> "Autark · Generiert"
runtimeState == RuntimeState.ERROR -> "Fehler" runtimeState == RuntimeState.ERROR -> "Fehler"
else -> "Bereit" else -> "Bereit"
} }
// FIX 3: else-Branch hinzugefügt — when-Ausdruck muss exhaustive sein
val statusColor = when (runtimeState) { val statusColor = when (runtimeState) {
RuntimeState.READY, RuntimeState.GENERATING -> successColor RuntimeState.READY, RuntimeState.GENERATING -> successColor
RuntimeState.LOADING -> warningColor RuntimeState.LOADING -> warningColor
@ -154,16 +238,12 @@ fun ODYChatScreen() {
else -> "🧠 Lokal" else -> "🧠 Lokal"
} }
val showSimBanner = engine.isSimulation
// P1+P2: systemBarsPadding() für Status-/Navigationsleiste,
// imePadding() für Tastatur — verhindert Layout-Shift und Überlagerung
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(bgColor) .background(bgColor)
.systemBarsPadding() .systemBarsPadding()
.imePadding() // P2: Eingabefeld bleibt über Tastatur .imePadding()
) { ) {
// ── Header ────────────────────────────────────────────────────────── // ── Header ──────────────────────────────────────────────────────────
Column( Column(
@ -209,7 +289,6 @@ fun ODYChatScreen() {
) )
} }
} }
// P7: HorizontalDivider statt deprecated Divider
HorizontalDivider( HorizontalDivider(
color = Color(0xFF2A2A2E), color = Color(0xFF2A2A2E),
thickness = 0.5.dp, thickness = 0.5.dp,
@ -217,19 +296,19 @@ fun ODYChatScreen() {
) )
} }
// ── Simulation-Banner (P4: kompakter) ─────────────────────────────── // ── Simulation-Banner ────────────────────────────────────────────────
if (showSimBanner) { if (engine.isSimulation) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp) .padding(horizontal = 12.dp, vertical = 3.dp)
.background(Color(0xFF2A1F00), RoundedCornerShape(8.dp)) .background(Color(0xFF2A1F00), RoundedCornerShape(8.dp))
.padding(horizontal = 14.dp, vertical = 6.dp) // P4: 8→6dp .padding(horizontal = 14.dp, vertical = 6.dp)
) { ) {
Text( Text(
text = "⚠ [Simulation] · Kein Modell geladen", text = "⚠ [Simulation] · Kein Modell geladen",
color = warningColor, color = warningColor,
fontSize = 12.sp, // P4: 13→12sp fontSize = 12.sp,
) )
} }
} }
@ -250,11 +329,7 @@ fun ODYChatScreen() {
strokeWidth = 2.dp, strokeWidth = 2.dp,
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(text = "Modell wird geladen...", color = accentColor, fontSize = 13.sp)
text = "Modell wird geladen...",
color = accentColor,
fontSize = 13.sp,
)
} }
} }
} }
@ -288,7 +363,6 @@ fun ODYChatScreen() {
// ── Chat-Nachrichten ───────────────────────────────────────────────── // ── Chat-Nachrichten ─────────────────────────────────────────────────
Box(modifier = Modifier.weight(1f)) { Box(modifier = Modifier.weight(1f)) {
// P3: Willkommens-Zustand wenn keine Nachrichten
if (messages.isEmpty() && currentOutput.isEmpty()) { if (messages.isEmpty() && currentOutput.isEmpty()) {
Column( Column(
modifier = Modifier modifier = Modifier
@ -297,11 +371,7 @@ fun ODYChatScreen() {
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text( Text(text = "🛡", fontSize = 40.sp, textAlign = TextAlign.Center)
text = "🛡",
fontSize = 40.sp,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = "ODY ist bereit.", text = "ODY ist bereit.",
@ -329,21 +399,19 @@ fun ODYChatScreen() {
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 12.dp), .padding(horizontal = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(vertical = 12.dp), contentPadding = PaddingValues(vertical = 12.dp),
) { ) {
items(messages) { (role, content) -> items(messages) { msg ->
val isUser = role == "user" val isUser = msg.role == "user"
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start, horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.82f) // P5: 280dp fix → 82% Breite .fillMaxWidth(0.82f)
.wrapContentWidth( .wrapContentWidth(if (isUser) Alignment.End else Alignment.Start)
if (isUser) Alignment.End else Alignment.Start
)
.background( .background(
if (isUser) userBubbleColor else odyBubbleColor, if (isUser) userBubbleColor else odyBubbleColor,
RoundedCornerShape( RoundedCornerShape(
@ -353,7 +421,7 @@ fun ODYChatScreen() {
bottomEnd = 16.dp, bottomEnd = 16.dp,
) )
) )
.padding(horizontal = 14.dp, vertical = 10.dp) .padding(horizontal = 14.dp, vertical = 12.dp)
) { ) {
Column { Column {
if (!isUser) { if (!isUser) {
@ -362,21 +430,27 @@ fun ODYChatScreen() {
color = if (engine.isSimulation) warningColor else accentColor, color = if (engine.isSimulation) warningColor else accentColor,
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 4.dp), modifier = Modifier.padding(bottom = 6.dp),
)
}
when (msg.state) {
MessageState.ERROR -> Text(
text = msg.content,
color = errorColor,
fontSize = 15.sp,
lineHeight = 24.sp,
)
else -> MarkdownText(
text = msg.content,
color = textPrimary,
) )
} }
Text(
text = content,
color = textPrimary,
fontSize = 15.sp,
lineHeight = 22.sp,
)
} }
} }
} }
} }
// Streaming-Bubble // Streaming-Bubble: Plaintext + Cursor während Generierung
if (currentOutput.isNotEmpty()) { if (currentOutput.isNotEmpty()) {
item { item {
Row( Row(
@ -385,12 +459,12 @@ fun ODYChatScreen() {
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.82f) // P5: konsistent .fillMaxWidth(0.82f)
.background( .background(
odyBubbleColor, odyBubbleColor,
RoundedCornerShape(4.dp, 16.dp, 16.dp, 16.dp), RoundedCornerShape(4.dp, 16.dp, 16.dp, 16.dp),
) )
.padding(horizontal = 14.dp, vertical = 10.dp) .padding(horizontal = 14.dp, vertical = 12.dp)
) { ) {
Column { Column {
Text( Text(
@ -398,13 +472,14 @@ fun ODYChatScreen() {
color = if (engine.isSimulation) warningColor else accentColor, color = if (engine.isSimulation) warningColor else accentColor,
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 4.dp), modifier = Modifier.padding(bottom = 6.dp),
) )
// Plaintext während Streaming — kein Markdown-Parser pro Token
Text( Text(
text = currentOutput + "", text = currentOutput + "",
color = textPrimary, color = textPrimary,
fontSize = 15.sp, fontSize = 15.sp,
lineHeight = 22.sp, lineHeight = 24.sp,
) )
} }
} }
@ -419,7 +494,7 @@ fun ODYChatScreen() {
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(surfaceColor) .background(surfaceColor)
.padding(horizontal = 12.dp, vertical = 10.dp), .padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
OutlinedTextField( OutlinedTextField(
@ -427,27 +502,23 @@ fun ODYChatScreen() {
onValueChange = { textInput = it }, onValueChange = { textInput = it },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
placeholder = { placeholder = {
Text( Text(text = "Frag ODY...", color = textSecondary, fontSize = 15.sp)
text = "Frag ODY...",
color = textSecondary,
fontSize = 15.sp,
)
}, },
colors = OutlinedTextFieldDefaults.colors( colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = accentColor, focusedBorderColor = accentColor,
unfocusedBorderColor = Color(0xFF3A3A4A), unfocusedBorderColor = Color(0xFF3A3A4A),
focusedTextColor = textPrimary, focusedTextColor = textPrimary,
unfocusedTextColor = textPrimary, unfocusedTextColor = textPrimary,
cursorColor = accentColor, cursorColor = accentColor,
focusedContainerColor = Color(0xFF1E1E24), focusedContainerColor = Color(0xFF1E1E24),
unfocusedContainerColor= Color(0xFF1A1A20), unfocusedContainerColor = Color(0xFF1A1A20),
), ),
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),
singleLine = false, singleLine = false,
maxLines = 4, maxLines = 4,
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(10.dp))
val isGenerating = runtimeState == RuntimeState.GENERATING val isGenerating = runtimeState == RuntimeState.GENERATING
@ -460,7 +531,7 @@ fun ODYChatScreen() {
if (userText.isBlank()) return@IconButton if (userText.isBlank()) return@IconButton
textInput = "" textInput = ""
messages = messages + ("user" to userText) messages = messages + ChatMessage(role = "user", content = userText)
currentOutput = "" currentOutput = ""
scope.launch(kotlinx.coroutines.Dispatchers.Main) { scope.launch(kotlinx.coroutines.Dispatchers.Main) {
@ -469,36 +540,56 @@ fun ODYChatScreen() {
} }
if (guardResult != null) { if (guardResult != null) {
guardMsg = guardResult guardMsg = guardResult
messages = messages + ("assistant" to "[Guard] ${guardResult.message}") messages = messages + ChatMessage(
role = "assistant",
content = "[Guard] ${guardResult.message}",
state = MessageState.ERROR,
)
return@launch return@launch
} }
// systemPrompt = null → LlamaCppRuntime verwendet ODY Constitution v1.1
val request = GenerationRequest( val request = GenerationRequest(
userMessage = userText, userMessage = userText,
systemPrompt = "Du bist ODY, ein lokales Werkzeug. Antworte präzise und direkt auf Deutsch. Keine Smalltalk-Floskeln.", systemPrompt = null,
history = messages.dropLast(1).takeLast(10), history = messages.dropLast(1)
.filter { it.state == MessageState.COMPLETE }
.map { it.role to it.content }
.takeLast(10),
) )
// onToken wird von LlamaCppRuntime bereits auf Main-Thread geliefert
// (via singleton mainHandler in LlamaCppRuntime).
// Hier kein Handler mehr noetig.
val genRunId = System.currentTimeMillis() val genRunId = System.currentTimeMillis()
android.util.Log.i("ODY_STREAM", "ui generate start runId=$genRunId") android.util.Log.i("ODY_STREAM", "ui generate start runId=$genRunId")
engine.generate(request) { token -> engine.generate(request) { token ->
android.util.Log.i("ODY_STREAM", "ui onToken len=${token.length} runId=$genRunId") android.util.Log.i("ODY_STREAM", "ui onToken len=${token.length} runId=$genRunId")
currentOutput += token currentOutput += token
} }
android.util.Log.i("ODY_STREAM", "ui generate done runId=$genRunId output_len=${currentOutput.length}") android.util.Log.i("ODY_STREAM", "ui generate done runId=$genRunId output_len=${currentOutput.length}")
// generate() ist suspend und laeuft in scope.launch (Dispatchers.Main) // Leere Antworten verhindern
// -> State-Mutation hier direkt erlaubt. val finalText = currentOutput.trim()
messages = messages + ("assistant" to currentOutput) val finalMsg = if (finalText.isBlank()) {
ChatMessage(
role = "assistant",
content = "Es wurde keine sichtbare Antwort erzeugt.",
state = MessageState.ERROR,
)
} else {
ChatMessage(
role = "assistant",
content = finalText,
state = MessageState.COMPLETE,
)
}
messages = messages + finalMsg
currentOutput = "" currentOutput = ""
} }
} }
}, },
modifier = Modifier modifier = Modifier
.size(48.dp) .size(46.dp)
.background( .background(
if (isGenerating) guardColor else accentColor, if (isGenerating) guardColor else accentColor,
RoundedCornerShape(50), RoundedCornerShape(50),
@ -524,7 +615,7 @@ fun ODYChatScreen() {
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "DEBUG · mmap16 · 1784582716 · $runtimeState", text = "DEBUG · mmap20 · $runtimeState",
color = textSecondary, color = textSecondary,
fontSize = 10.sp, fontSize = 10.sp,
) )
@ -544,14 +635,21 @@ fun ODYChatScreen() {
} }
} }
// P6: Auto-Scroll-Guard — kein IndexOutOfBoundsException // Auto-Scroll: nur nach Abschluss einer Antwort, nur wenn Nutzer unten war
// P8: Scroll nur bei neuer Nachricht, nicht bei jedem Token val messageCount = messages.size
// animateScrollToItem während Streaming erzeugt einen Scroll-Loop LaunchedEffect(messageCount) {
// FIX mmap14: Scroll auch wenn Streaming-Blase erscheint (currentOutput wird nicht-leer) // Nur scrollen wenn Nutzer am Ende war (kein manuelles Hochscrollen)
val scrollTrigger = messages.size + if (currentOutput.isNotEmpty()) 1 else 0 val wasNearBottom = !listState.canScrollForward
LaunchedEffect(scrollTrigger) { if (wasNearBottom && messageCount > 0) {
val totalItems = messages.size + if (currentOutput.isNotEmpty()) 1 else 0 listState.scrollToItem(index = messageCount - 1)
if (totalItems > 0) { }
}
// Während Streaming: ans Ende scrollen wenn neue Streaming-Blase erscheint
val isStreaming = currentOutput.isNotEmpty()
LaunchedEffect(isStreaming) {
if (isStreaming) {
val totalItems = messages.size + 1
listState.scrollToItem(index = (totalItems - 1).coerceAtLeast(0)) listState.scrollToItem(index = (totalItems - 1).coerceAtLeast(0))
} }
} }