@@ -24,6 +24,7 @@ import cc.unitmesh.devins.llm.Message
2424import cc.unitmesh.devins.llm.MessageRole
2525import cc.unitmesh.devins.ui.compose.icons.AutoDevComposeIcons
2626import cc.unitmesh.devins.ui.compose.sketch.SketchRenderer
27+ import kotlinx.coroutines.delay
2728import kotlinx.coroutines.launch
2829import 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 }
0 commit comments