Skip to content

Commit 3f6c2e1

Browse files
committed
fix(ui): improve auto-scroll behavior for streaming messages #453
Enhances auto-scroll logic in AgentMessageList to avoid interrupting users who scroll up, and ensures smooth scrolling as new streaming content or blocks are rendered. Also updates tool name display for "docql".
1 parent 814dfd0 commit 3f6c2e1

File tree

2 files changed

+78
-16
lines changed

2 files changed

+78
-16
lines changed

mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentMessageList.kt

Lines changed: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import cc.unitmesh.devins.llm.Message
2424
import cc.unitmesh.devins.llm.MessageRole
2525
import cc.unitmesh.devins.ui.compose.icons.AutoDevComposeIcons
2626
import cc.unitmesh.devins.ui.compose.sketch.SketchRenderer
27+
import kotlinx.coroutines.delay
2728
import kotlinx.coroutines.launch
2829
import org.jetbrains.compose.resources.Font
2930

@@ -36,34 +37,84 @@ fun AgentMessageList(
3637
val listState = rememberLazyListState()
3738
val coroutineScope = rememberCoroutineScope()
3839

39-
LaunchedEffect(renderer.timeline.size, renderer.currentStreamingOutput) {
40-
if (renderer.timeline.isNotEmpty() || renderer.currentStreamingOutput.isNotEmpty()) {
40+
// Track if user manually scrolled away from bottom
41+
var userScrolledAway by remember { mutableStateOf(false) }
42+
43+
// Track content updates from SketchRenderer for streaming content
44+
var streamingBlockCount by remember { mutableIntStateOf(0) }
45+
46+
// Function to scroll to bottom
47+
fun scrollToBottomIfNeeded() {
48+
if (!userScrolledAway) {
4149
coroutineScope.launch {
42-
listState.animateScrollToItem(
43-
index = maxOf(0, listState.layoutInfo.totalItemsCount - 1)
44-
)
50+
// Delay to ensure layout is complete before scrolling
51+
delay(50)
52+
val lastIndex = maxOf(0, listState.layoutInfo.totalItemsCount - 1)
53+
listState.scrollToItem(lastIndex)
4554
}
4655
}
4756
}
4857

49-
LaunchedEffect(renderer.currentStreamingOutput.length) {
50-
if (renderer.currentStreamingOutput.isNotEmpty()) {
58+
// Monitor scroll state to detect user scrolling away
59+
LaunchedEffect(listState.isScrollInProgress) {
60+
if (listState.isScrollInProgress) {
61+
val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
62+
val totalItems = listState.layoutInfo.totalItemsCount
63+
// If user scrolled to a position not near the bottom, they want to view history
64+
userScrolledAway = lastVisibleIndex < totalItems - 2
65+
}
66+
}
67+
68+
// Scroll when timeline changes (new messages, tool calls, etc.)
69+
LaunchedEffect(renderer.timeline.size) {
70+
if (renderer.timeline.isNotEmpty()) {
71+
userScrolledAway = false
5172
coroutineScope.launch {
52-
listState.scrollToItem(
73+
delay(50)
74+
listState.animateScrollToItem(
5375
index = maxOf(0, listState.layoutInfo.totalItemsCount - 1)
5476
)
5577
}
5678
}
5779
}
5880

81+
// Scroll when streaming content changes
82+
LaunchedEffect(renderer.currentStreamingOutput) {
83+
if (renderer.currentStreamingOutput.isNotEmpty()) {
84+
// Calculate content signature based on line count and character chunks
85+
val lineCount = renderer.currentStreamingOutput.count { it == '\n' }
86+
val chunkIndex = renderer.currentStreamingOutput.length / 100
87+
val contentSignature = lineCount + chunkIndex
88+
89+
// Delay to ensure Markdown layout is complete
90+
delay(100)
91+
scrollToBottomIfNeeded()
92+
}
93+
}
94+
95+
// Scroll when SketchRenderer reports new blocks rendered
96+
LaunchedEffect(streamingBlockCount) {
97+
if (streamingBlockCount > 0) {
98+
delay(50)
99+
scrollToBottomIfNeeded()
100+
}
101+
}
102+
103+
// Reset user scroll state when streaming starts
104+
LaunchedEffect(renderer.isProcessing) {
105+
if (renderer.isProcessing) {
106+
userScrolledAway = false
107+
}
108+
}
109+
59110
LazyColumn(
60111
state = listState,
61112
modifier =
62113
modifier
63114
.fillMaxSize()
64115
.background(MaterialTheme.colorScheme.surface),
65-
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), // Reduce padding
66-
verticalArrangement = Arrangement.spacedBy(6.dp) // Reduce spacing
116+
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
117+
verticalArrangement = Arrangement.spacedBy(6.dp)
67118
) {
68119
items(renderer.timeline) { timelineItem ->
69120
RenderMessageItem(
@@ -72,17 +123,23 @@ fun AgentMessageList(
72123
renderer = renderer,
73124
onExpand = {
74125
coroutineScope.launch {
75-
// Scroll to the bottom when an item expands, to ensure visibility
76-
// This fixes the issue where expanding an item (like a tool result) doesn't trigger auto-scroll
126+
// Scroll to the bottom when an item expands
127+
delay(50)
77128
listState.animateScrollToItem(maxOf(0, listState.layoutInfo.totalItemsCount - 1))
78129
}
79130
}
80131
)
81132
}
82133

83134
if (renderer.currentStreamingOutput.isNotEmpty()) {
84-
item {
85-
StreamingMessageItem(content = renderer.currentStreamingOutput)
135+
item(key = "streaming") {
136+
StreamingMessageItem(
137+
content = renderer.currentStreamingOutput,
138+
onContentUpdate = { blockCount ->
139+
// When SketchRenderer renders new blocks, trigger scroll
140+
streamingBlockCount = blockCount
141+
}
142+
)
86143
}
87144
}
88145

@@ -237,7 +294,10 @@ expect fun PlatformMessageTextContainer(
237294
)
238295

239296
@Composable
240-
fun StreamingMessageItem(content: String) {
297+
fun StreamingMessageItem(
298+
content: String,
299+
onContentUpdate: (blockCount: Int) -> Unit = {}
300+
) {
241301
Surface(
242302
color = MaterialTheme.colorScheme.surfaceVariant,
243303
shape = RoundedCornerShape(4.dp)
@@ -246,9 +306,11 @@ fun StreamingMessageItem(content: String) {
246306
if (content.isNotEmpty()) {
247307
Spacer(modifier = Modifier.height(8.dp))
248308
// Use SketchRenderer to support thinking blocks in streaming content
309+
// Pass onContentUpdate to trigger scroll when new blocks are rendered
249310
SketchRenderer.RenderResponse(
250311
content = content,
251312
isComplete = false,
313+
onContentUpdate = onContentUpdate,
252314
modifier = Modifier.fillMaxWidth()
253315
)
254316
}

mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ComposeRenderer.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,7 @@ class ComposeRenderer : BaseRenderer() {
574574

575575
else ->
576576
ToolCallInfo(
577-
toolName = toolName,
577+
toolName = if (toolName == "docql") "DocQL" else toolName,
578578
description = "tool execution",
579579
details = paramsStr
580580
)

0 commit comments

Comments
 (0)