git.iko.soy/opencode-android
on master help sign in
Remove per-item scroll overhead: drop animateItem, SwipeToDismissBox, and skip markdown throttle for static messages
commit278b0845f4913296f3a7666829cc63b28e9fe2d5 authoriko <mail@iko.soy> dateWed 1 Jul 2026, 03:29 (3 months ago) parent45321b6
5 files changed+121−134
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+1−1 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+58−38 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt−1 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+56−94 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+6
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+1−1
@@ -75,7 +75,7 @@ private const val COLLAPSED_LIMIT = 600
7575fun PartView(part: Part, modifier: Modifier = Modifier, isRunning: Boolean = false, imageContext: ImageLoadContext? = null) {
7676 when (part) {
7777 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
78− MarkdownText(part.text, modifier = modifier)
78+ MarkdownText(part.text, modifier = modifier, streaming = isRunning)
7979 }
8080 is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, modifier)
8181 is ToolPart -> ToolCallView(part, modifier)
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+58−38
@@ -66,6 +66,10 @@ import soy.iko.opencode.data.network.NetworkConfig
6666 * starving the render and showing stale content until the stream pauses. The conflated
6767 * `collect` lets the delay run to completion, then picks up the most recent buffered value,
6868 * so rendering always progresses even under continuous fast streaming.
69+ *
70+ * When [streaming] is false (the common case: every non-active message in the chat list),
71+ * the throttle pipeline is skipped entirely — no `LaunchedEffect`, no `snapshotFlow`, no
72+ * coroutine. This eliminates per-item coroutine churn as messages scroll in and out of view.
6973 */
7074@OptIn(ExperimentalFoundationApi::class)
7175@Composable
@@ -73,48 +77,64 @@ fun MarkdownText(
7377 markdown: String,
7478 modifier: Modifier = Modifier,
7579 style: TextStyle = MaterialTheme.typography.bodyLarge,
80+ streaming: Boolean = false,
7681) {
7782 val context = LocalContext.current
78− // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
79− val markdownState = rememberUpdatedState(markdown)
80− // Keyed to a content-prefix so switching to a different message resets
81− // immediately instead of showing the old message for one frame.
82− var renderedContent by remember(markdown.take(32)) { mutableStateOf(markdown) }
83− // conflate() coalesces a burst of token updates into a single emission so the
84− // collector sees only the latest value after the previous delay completes. Plain
85− // collect (not collectLatest) is critical: collectLatest would cancel the delay
86− // on every new value, reintroducing the same starvation the keyed effect had.
87− LaunchedEffect(markdown.take(32)) {
88− snapshotFlow { markdownState.value }
89− .conflate()
90− .collect { md ->
91− // Only throttle when the content is growing incrementally (streaming):
92− // a content switch or initial render proceeds immediately. Checking
93− // length is O(1) vs. startsWith which is O(n) in the rendered content
94− // length — during a long streaming response this runs every throttle
95− // cycle. The keyed LaunchedEffect(markdown.take(32)) already handles
96− // content switches; within one keyed cycle, growing length means
97− // appending (streaming), not a rewrite.
98− if (renderedContent.isNotEmpty() &&
99− md.length > renderedContent.length
100− ) {
101− delay(NetworkConfig.streamingThrottleMs)
102− }
103− renderedContent = md
104− }
105− }
106− Markdown(
107− content = renderedContent,
108− modifier = modifier.combinedClickable(
109− onClick = {},
110− onLongClick = { copyToClipboard(context, markdown) },
111− role = Role.Button,
112− ),
113− components = markdownComponents(
83+ val components = remember {
84+ markdownComponents(
11485 codeFence = { CodeWithCopy(it) },
11586 codeBlock = { CodeWithCopy(it) },
116− ),
117− )
87+ )
88+ }
89+ if (streaming) {
90+ // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
91+ val markdownState = rememberUpdatedState(markdown)
92+ // Keyed to a content-prefix so switching to a different message resets
93+ // immediately instead of showing the old message for one frame.
94+ var renderedContent by remember(markdown.take(32)) { mutableStateOf(markdown) }
95+ // conflate() coalesces a burst of token updates into a single emission so the
96+ // collector sees only the latest value after the previous delay completes. Plain
97+ // collect (not collectLatest) is critical: collectLatest would cancel the delay
98+ // on every new value, reintroducing the same starvation the keyed effect had.
99+ LaunchedEffect(markdown.take(32)) {
100+ snapshotFlow { markdownState.value }
101+ .conflate()
102+ .collect { md ->
103+ // Only throttle when the content is growing incrementally (streaming):
104+ // a content switch or initial render proceeds immediately. Checking
105+ // length is O(1) vs. startsWith which is O(n) in the rendered content
106+ // length — during a long streaming response this runs every throttle
107+ // cycle. The keyed LaunchedEffect(markdown.take(32)) already handles
108+ // content switches; within one keyed cycle, growing length means
109+ // appending (streaming), not a rewrite.
110+ if (renderedContent.isNotEmpty() &&
111+ md.length > renderedContent.length
112+ ) {
113+ delay(NetworkConfig.streamingThrottleMs)
114+ }
115+ renderedContent = md
116+ }
117+ }
118+ Markdown(
119+ content = renderedContent,
120+ modifier = modifier.combinedClickable(
121+ onClick = {},
122+ onLongClick = { copyToClipboard(context, markdown) },
123+ role = Role.Button,
124+ ),
125+ components = components,
126+ )
127+ } else {
128+ Markdown(
129+ content = markdown,
130+ modifier = modifier.combinedClickable(
131+ onClick = {},
132+ onLongClick = { copyToClipboard(context, markdown) },
133+ role = Role.Button,
134+ ),
135+ components = components,
136+ )
137+ }
118138}
119139
120140/**
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt−1
@@ -121,7 +121,6 @@ fun ServerListScreen(
121121 Card(
122122 modifier = Modifier
123123 .fillMaxWidth()
124− .animateItem()
125124 .testTag("server_card")
126125 .clickable(enabled = connectingId == null, role = Role.Button) {
127126 vm.connect(profile, onConnected)
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+56−94
@@ -43,12 +43,10 @@ import androidx.compose.material3.OutlinedTextField
4343import androidx.compose.material3.Scaffold
4444import androidx.compose.material3.SnackbarHost
4545import androidx.compose.material3.SnackbarHostState
46−import androidx.compose.material3.SwipeToDismissBox
4746import androidx.compose.material3.Text
4847import androidx.compose.material3.TextButton
4948import androidx.compose.material3.TopAppBar
5049import androidx.compose.material3.pulltorefresh.PullToRefreshBox
51−import androidx.compose.material3.rememberSwipeToDismissBoxState
5250import androidx.compose.runtime.Composable
5351import androidx.compose.runtime.CompositionLocalProvider
5452import androidx.compose.runtime.LaunchedEffect
@@ -261,7 +259,7 @@ fun SessionListScreen(
261259 onClick = { onOpenSession(session.id) },
262260 onRename = { pendingRenameId = session.id },
263261 onDelete = { pendingDeleteId = session.id },
264− modifier = Modifier.animateItem().testTag("session_card"),
262+ modifier = Modifier.testTag("session_card"),
265263 )
266264 }
267265 }
@@ -348,110 +346,74 @@ private fun SessionCard(
348346 onDelete: () -> Unit,
349347 modifier: Modifier = Modifier,
350348) {
351− // Swipe toward the start reveals a delete affordance; releasing snaps back and opens
352− // the same confirmation dialog the trash icon uses, so the destructive action is
353− // always confirmed.
354− val dismissState = rememberSwipeToDismissBoxState(
355− confirmValueChange = {
356− onDelete()
357− false // snap back; the dialog owns the actual deletion
358− },
359− )
360− SwipeToDismissBox(
361− state = dismissState,
362− backgroundContent = { SwipeDeleteBackground() },
363− enableDismissFromStartToEnd = false,
364− modifier = modifier,
349+ Card(
350+ modifier = modifier.fillMaxWidth(),
365351 ) {
366− Card(
367− modifier = Modifier.fillMaxWidth(),
368− ) {
369− Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
370− Row(
371− modifier = Modifier.fillMaxWidth(),
372− verticalAlignment = Alignment.CenterVertically,
352+ Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
353+ Row(
354+ modifier = Modifier.fillMaxWidth(),
355+ verticalAlignment = Alignment.CenterVertically,
356+ ) {
357+ // Title area is the primary click target; the action buttons below
358+ // remain separate accessibility nodes so screen readers can activate
359+ // them independently.
360+ Column(
361+ modifier = Modifier
362+ .weight(1f)
363+ .clickable(role = Role.Button) { onClick() },
373364 ) {
374− // Title area is the primary click target; the action buttons below
375− // remain separate accessibility nodes so screen readers can activate
376− // them independently.
377− Column(
378− modifier = Modifier
379− .weight(1f)
380− .clickable(role = Role.Button) { onClick() },
381− ) {
382− Row(verticalAlignment = Alignment.CenterVertically) {
383− if (unread) {
384− val unreadLabel = stringResource(R.string.unread)
385− Box(
386− modifier = Modifier
387− .padding(end = 8.dp)
388− .size(8.dp)
389− .clip(androidx.compose.foundation.shape.CircleShape)
390− .background(MaterialTheme.colorScheme.primary)
391− .semantics { contentDescription = unreadLabel },
392− )
393− }
394− Text(
395− session.displayTitle,
396− style = MaterialTheme.typography.titleMedium,
397− color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
398− maxLines = 1,
399− overflow = TextOverflow.Ellipsis,
400− )
401− }
402− val time = rememberRelativeTime(session.time?.updated ?: session.time?.created)
403− if (time.isNotEmpty()) {
404− Text(
405− time,
406− style = MaterialTheme.typography.labelSmall,
407− color = MaterialTheme.colorScheme.onSurfaceVariant,
408− modifier = Modifier.padding(top = 2.dp),
365+ Row(verticalAlignment = Alignment.CenterVertically) {
366+ if (unread) {
367+ val unreadLabel = stringResource(R.string.unread)
368+ Box(
369+ modifier = Modifier
370+ .padding(end = 8.dp)
371+ .size(8.dp)
372+ .clip(androidx.compose.foundation.shape.CircleShape)
373+ .background(MaterialTheme.colorScheme.primary)
374+ .semantics { contentDescription = unreadLabel },
409375 )
410376 }
377+ Text(
378+ session.displayTitle,
379+ style = MaterialTheme.typography.titleMedium,
380+ color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
381+ maxLines = 1,
382+ overflow = TextOverflow.Ellipsis,
383+ )
411384 }
412− IconButton(onClick = onRename) {
413− Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.rename))
414− }
415− IconButton(onClick = onDelete) {
416− Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
385+ val time = rememberRelativeTime(session.time?.updated ?: session.time?.created)
386+ if (time.isNotEmpty()) {
387+ Text(
388+ time,
389+ style = MaterialTheme.typography.labelSmall,
390+ color = MaterialTheme.colorScheme.onSurfaceVariant,
391+ modifier = Modifier.padding(top = 2.dp),
392+ )
417393 }
418394 }
419− if (!preview.isNullOrBlank()) {
420− Spacer(Modifier.size(4.dp))
421− Text(
422− preview,
423− style = MaterialTheme.typography.bodyMedium,
424− color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
425− maxLines = 2,
426− overflow = TextOverflow.Ellipsis,
427− modifier = Modifier.clickable(role = Role.Button) { onClick() },
428− )
395+ IconButton(onClick = onRename) {
396+ Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.rename))
397+ }
398+ IconButton(onClick = onDelete) {
399+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
429400 }
430401 }
402+ if (!preview.isNullOrBlank()) {
403+ Spacer(Modifier.size(4.dp))
404+ Text(
405+ preview,
406+ style = MaterialTheme.typography.bodyMedium,
407+ color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
408+ maxLines = 2,
409+ overflow = TextOverflow.Ellipsis,
410+ modifier = Modifier.clickable(role = Role.Button) { onClick() },
411+ )
412+ }
431413 }
432414 }
433415}
434416
435−/** Red background with a trash icon shown behind a session card as it is swiped away. */
436−@Composable
437−private fun SwipeDeleteBackground() {
438− Box(
439− modifier = Modifier
440− .fillMaxSize()
441− .clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp))
442− .background(MaterialTheme.colorScheme.errorContainer)
443− .padding(horizontal = 20.dp),
444− contentAlignment = Alignment.CenterEnd,
445− ) {
446− val deleteLabel = stringResource(R.string.delete)
447− Icon(
448− Icons.Filled.Delete,
449− contentDescription = deleteLabel,
450− tint = MaterialTheme.colorScheme.onErrorContainer,
451− )
452− }
453−}
454−
455417@Composable
456418private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
457419 Column(
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+6
@@ -31,6 +31,7 @@ import androidx.compose.material3.Text
3131import androidx.compose.material3.TextButton
3232import androidx.compose.material3.TopAppBar
3333import androidx.compose.runtime.Composable
34+import androidx.compose.runtime.CompositionLocalProvider
3435import androidx.compose.runtime.LaunchedEffect
3536import androidx.compose.runtime.getValue
3637import androidx.compose.runtime.mutableStateOf
@@ -55,7 +56,9 @@ import androidx.compose.ui.unit.dp
5556import androidx.lifecycle.compose.collectAsStateWithLifecycle
5657import soy.iko.opencode.R
5758import soy.iko.opencode.data.repo.CrashLogger
59+import soy.iko.opencode.ui.components.LocalRelativeTimeTick
5860import soy.iko.opencode.ui.components.rememberRelativeTime
61+import soy.iko.opencode.ui.components.rememberRelativeTimeTick
5962
6063@OptIn(ExperimentalMaterial3Api::class)
6164@Composable
@@ -66,6 +69,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
6669 var viewing by rememberSaveable { mutableStateOf<String?>(null) }
6770 val shareScope = rememberCoroutineScope()
6871 val shareLabel = stringResource(R.string.share)
72+ val timeTick = rememberRelativeTimeTick()
6973
7074 Scaffold(
7175 topBar = {
@@ -88,6 +92,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
8892 )
8993 },
9094 ) { padding ->
95+ CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
9196 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
9297 Text(
9398 stringResource(R.string.diagnostics_desc),
@@ -156,6 +161,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
156161 }
157162 }
158163 }
164+ }
159165 }
160166
161167 val reportName = viewing