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
278b084iko
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+1−1
@@ -75,7 +75,7 @@ private const val COLLAPSED_LIMIT = 600
75fun PartView(part: Part, modifier: Modifier = Modifier, isRunning: Boolean = false, imageContext: ImageLoadContext? = null) {
75fun PartView(part: Part, modifier: Modifier = Modifier, isRunning: Boolean = false, imageContext: ImageLoadContext? = null) {
76 when (part) {
76 when (part) {
77 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
77 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
78− MarkdownText(part.text, modifier = modifier)
78+ MarkdownText(part.text, modifier = modifier, streaming = isRunning)
79 }
79 }
80 is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, modifier)
80 is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, modifier)
81 is ToolPart -> ToolCallView(part, modifier)
81 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
66 * starving the render and showing stale content until the stream pauses. The conflated
66 * starving the render and showing stale content until the stream pauses. The conflated
67 * `collect` lets the delay run to completion, then picks up the most recent buffered value,
67 * `collect` lets the delay run to completion, then picks up the most recent buffered value,
68 * so rendering always progresses even under continuous fast streaming.
68 * 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.
69 */
73 */
70@OptIn(ExperimentalFoundationApi::class)
74@OptIn(ExperimentalFoundationApi::class)
71@Composable
75@Composable
@@ -73,48 +77,64 @@ fun MarkdownText(
73 markdown: String,
77 markdown: String,
74 modifier: Modifier = Modifier,
78 modifier: Modifier = Modifier,
75 style: TextStyle = MaterialTheme.typography.bodyLarge,
79 style: TextStyle = MaterialTheme.typography.bodyLarge,
80+ streaming: Boolean = false,
76) {
81) {
77 val context = LocalContext.current
82 val context = LocalContext.current
78− // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
83+ val components = remember {
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(
84+ markdownComponents(
114 codeFence = { CodeWithCopy(it) },
85 codeFence = { CodeWithCopy(it) },
115 codeBlock = { CodeWithCopy(it) },
86 codeBlock = { CodeWithCopy(it) },
116− ),
87+ )
117− )
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+ }
118}
138}
119
139
120/**
140/**
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt−1
@@ -121,7 +121,6 @@ fun ServerListScreen(
121 Card(
121 Card(
122 modifier = Modifier
122 modifier = Modifier
123 .fillMaxWidth()
123 .fillMaxWidth()
124− .animateItem()
125 .testTag("server_card")
124 .testTag("server_card")
126 .clickable(enabled = connectingId == null, role = Role.Button) {
125 .clickable(enabled = connectingId == null, role = Role.Button) {
127 vm.connect(profile, onConnected)
126 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
43import androidx.compose.material3.Scaffold
43import androidx.compose.material3.Scaffold
44import androidx.compose.material3.SnackbarHost
44import androidx.compose.material3.SnackbarHost
45import androidx.compose.material3.SnackbarHostState
45import androidx.compose.material3.SnackbarHostState
46−import androidx.compose.material3.SwipeToDismissBox
47import androidx.compose.material3.Text
46import androidx.compose.material3.Text
48import androidx.compose.material3.TextButton
47import androidx.compose.material3.TextButton
49import androidx.compose.material3.TopAppBar
48import androidx.compose.material3.TopAppBar
50import androidx.compose.material3.pulltorefresh.PullToRefreshBox
49import androidx.compose.material3.pulltorefresh.PullToRefreshBox
51−import androidx.compose.material3.rememberSwipeToDismissBoxState
52import androidx.compose.runtime.Composable
50import androidx.compose.runtime.Composable
53import androidx.compose.runtime.CompositionLocalProvider
51import androidx.compose.runtime.CompositionLocalProvider
54import androidx.compose.runtime.LaunchedEffect
52import androidx.compose.runtime.LaunchedEffect
@@ -261,7 +259,7 @@ fun SessionListScreen(
261 onClick = { onOpenSession(session.id) },
259 onClick = { onOpenSession(session.id) },
262 onRename = { pendingRenameId = session.id },
260 onRename = { pendingRenameId = session.id },
263 onDelete = { pendingDeleteId = session.id },
261 onDelete = { pendingDeleteId = session.id },
264− modifier = Modifier.animateItem().testTag("session_card"),
262+ modifier = Modifier.testTag("session_card"),
265 )
263 )
266 }
264 }
267 }
265 }
@@ -348,110 +346,74 @@ private fun SessionCard(
348 onDelete: () -> Unit,
346 onDelete: () -> Unit,
349 modifier: Modifier = Modifier,
347 modifier: Modifier = Modifier,
350) {
348) {
351− // Swipe toward the start reveals a delete affordance; releasing snaps back and opens
349+ Card(
352− // the same confirmation dialog the trash icon uses, so the destructive action is
350+ modifier = modifier.fillMaxWidth(),
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,
365 ) {
351 ) {
366− Card(
367− modifier = Modifier.fillMaxWidth(),
368− ) {
369− Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
352+ Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
370− Row(
353+ Row(
371− modifier = Modifier.fillMaxWidth(),
354+ modifier = Modifier.fillMaxWidth(),
372− verticalAlignment = Alignment.CenterVertically,
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() },
373 ) {
364 ) {
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) {
365+ Row(verticalAlignment = Alignment.CenterVertically) {
383− if (unread) {
366+ if (unread) {
384− val unreadLabel = stringResource(R.string.unread)
367+ val unreadLabel = stringResource(R.string.unread)
385− Box(
368+ Box(
386− modifier = Modifier
369+ modifier = Modifier
387− .padding(end = 8.dp)
370+ .padding(end = 8.dp)
388− .size(8.dp)
371+ .size(8.dp)
389− .clip(androidx.compose.foundation.shape.CircleShape)
372+ .clip(androidx.compose.foundation.shape.CircleShape)
390− .background(MaterialTheme.colorScheme.primary)
373+ .background(MaterialTheme.colorScheme.primary)
391− .semantics { contentDescription = unreadLabel },
374+ .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),
409 )
375 )
410 }
376 }
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+ )
411 }
384 }
412− IconButton(onClick = onRename) {
385+ val time = rememberRelativeTime(session.time?.updated ?: session.time?.created)
413− Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.rename))
386+ if (time.isNotEmpty()) {
414− }
387+ Text(
415− IconButton(onClick = onDelete) {
388+ time,
416− Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
389+ style = MaterialTheme.typography.labelSmall,
390+ color = MaterialTheme.colorScheme.onSurfaceVariant,
391+ modifier = Modifier.padding(top = 2.dp),
392+ )
417 }
393 }
418 }
394 }
419− if (!preview.isNullOrBlank()) {
395+ IconButton(onClick = onRename) {
420− Spacer(Modifier.size(4.dp))
396+ Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.rename))
421− Text(
397+ }
422− preview,
398+ IconButton(onClick = onDelete) {
423− style = MaterialTheme.typography.bodyMedium,
399+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
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− )
429 }
400 }
430 }
401 }
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+ }
431 }
413 }
432 }
414 }
433}
415}
434
416
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−
455@Composable
417@Composable
456private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
418private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
457 Column(
419 Column(
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+6
@@ -31,6 +31,7 @@ import androidx.compose.material3.Text
31import androidx.compose.material3.TextButton
31import androidx.compose.material3.TextButton
32import androidx.compose.material3.TopAppBar
32import androidx.compose.material3.TopAppBar
33import androidx.compose.runtime.Composable
33import androidx.compose.runtime.Composable
34+import androidx.compose.runtime.CompositionLocalProvider
34import androidx.compose.runtime.LaunchedEffect
35import androidx.compose.runtime.LaunchedEffect
35import androidx.compose.runtime.getValue
36import androidx.compose.runtime.getValue
36import androidx.compose.runtime.mutableStateOf
37import androidx.compose.runtime.mutableStateOf
@@ -55,7 +56,9 @@ import androidx.compose.ui.unit.dp
55import androidx.lifecycle.compose.collectAsStateWithLifecycle
56import androidx.lifecycle.compose.collectAsStateWithLifecycle
56import soy.iko.opencode.R
57import soy.iko.opencode.R
57import soy.iko.opencode.data.repo.CrashLogger
58import soy.iko.opencode.data.repo.CrashLogger
59+import soy.iko.opencode.ui.components.LocalRelativeTimeTick
58import soy.iko.opencode.ui.components.rememberRelativeTime
60import soy.iko.opencode.ui.components.rememberRelativeTime
61+import soy.iko.opencode.ui.components.rememberRelativeTimeTick
59
62
60@OptIn(ExperimentalMaterial3Api::class)
63@OptIn(ExperimentalMaterial3Api::class)
61@Composable
64@Composable
@@ -66,6 +69,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
66 var viewing by rememberSaveable { mutableStateOf<String?>(null) }
69 var viewing by rememberSaveable { mutableStateOf<String?>(null) }
67 val shareScope = rememberCoroutineScope()
70 val shareScope = rememberCoroutineScope()
68 val shareLabel = stringResource(R.string.share)
71 val shareLabel = stringResource(R.string.share)
72+ val timeTick = rememberRelativeTimeTick()
69
73
70 Scaffold(
74 Scaffold(
71 topBar = {
75 topBar = {
@@ -88,6 +92,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
88 )
92 )
89 },
93 },
90 ) { padding ->
94 ) { padding ->
95+ CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
91 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
96 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
92 Text(
97 Text(
93 stringResource(R.string.diagnostics_desc),
98 stringResource(R.string.diagnostics_desc),
@@ -156,6 +161,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
156 }
161 }
157 }
162 }
158 }
163 }
164+ }
159 }
165 }
160
166
161 val reportName = viewing
167 val reportName = viewing