Skip to content

Commit fd96dc5

Browse files
authored
Merge pull request #455 from unit-mesh/feat-smart-message-scroll-ff463
Refactor MessageList for improved auto-scrolling behavior and user in…
2 parents df046c3 + 4435f37 commit fd96dc5

File tree

16 files changed

+238
-25
lines changed

16 files changed

+238
-25
lines changed

docs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Subproject commit c2fa2a1c0efbe605eaea195812f89d330a57307b

mpp-core/src/commonMain/kotlin/cc/unitmesh/devins/db/DatabaseDriverFactory.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ fun createDatabase(driverFactory: DatabaseDriverFactory): DevInsDatabase {
2222

2323

2424

25+

mpp-core/src/commonMain/sqldelight/cc/unitmesh/devins/db/ModelConfig.sq

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,4 @@ DELETE FROM ModelConfig;
6161

6262

6363

64+

mpp-core/src/jvmMain/kotlin/cc/unitmesh/devins/db/DatabaseDriverFactory.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,4 @@ actual class DatabaseDriverFactory {
3838

3939

4040

41+

mpp-ui/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,3 +306,4 @@ npm run clean
306306

307307
与主项目 AutoDev 相同
308308

309+

mpp-ui/src/androidMain/kotlin/cc/unitmesh/devins/ui/MainActivity.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,4 @@ class MainActivity : ComponentActivity() {
2929
}
3030

3131

32+

mpp-ui/src/androidMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/MarkdownSketchRenderer.android.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,4 @@ actual object MarkdownSketchRenderer {
189189
}
190190
}
191191

192+

mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/chat/MessageList.kt

Lines changed: 86 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@ import androidx.compose.foundation.lazy.LazyColumn
55
import androidx.compose.foundation.lazy.items
66
import androidx.compose.foundation.lazy.rememberLazyListState
77
import androidx.compose.material3.*
8-
import androidx.compose.runtime.Composable
9-
import androidx.compose.runtime.LaunchedEffect
10-
import androidx.compose.runtime.remember
8+
import androidx.compose.runtime.*
119
import androidx.compose.ui.Alignment
1210
import androidx.compose.ui.Modifier
1311
import androidx.compose.ui.text.font.FontFamily
@@ -16,10 +14,17 @@ import cc.unitmesh.devins.filesystem.ProjectFileSystem
1614
import cc.unitmesh.devins.llm.Message
1715
import cc.unitmesh.devins.llm.MessageRole
1816
import cc.unitmesh.devins.ui.compose.sketch.SketchRenderer
17+
import kotlinx.coroutines.delay
18+
import kotlinx.coroutines.launch
1919

2020
/**
2121
* 消息列表组件
2222
* 显示完整的对话历史,使用连续流式布局
23+
*
24+
* 优化的滚动策略:
25+
* 1. 检测用户是否手动滚动
26+
* 2. 流式输出时持续滚动到底部(除非用户主动向上滚动)
27+
* 3. 新消息到达时自动滚动
2328
*/
2429
@Composable
2530
fun MessageList(
@@ -31,17 +36,74 @@ fun MessageList(
3136
modifier: Modifier = Modifier
3237
) {
3338
val listState = rememberLazyListState()
39+
val coroutineScope = rememberCoroutineScope()
3440

35-
// 自动滚动到底部
36-
LaunchedEffect(messages.size, currentOutput) {
37-
if (messages.isNotEmpty() || currentOutput.isNotEmpty()) {
38-
// 总是滚动到最后一项
39-
val targetIndex = if (isLLMProcessing && currentOutput.isNotEmpty()) {
40-
messages.size // 流式输出项的索引
41-
} else {
42-
maxOf(0, messages.size - 1)
41+
// 跟踪用户是否主动向上滚动
42+
var userScrolledAway by remember { mutableStateOf(false) }
43+
44+
// 跟踪上次触发滚动的时间,避免过于频繁
45+
var lastScrollTime by remember { mutableStateOf(0L) }
46+
47+
// 使用 derivedStateOf 来减少重组,只在真正需要时才触发
48+
val shouldAutoScroll by remember {
49+
derivedStateOf {
50+
isLLMProcessing && !userScrolledAway && currentOutput.isNotEmpty()
51+
}
52+
}
53+
54+
// 滚动到底部的辅助函数(带防抖)
55+
fun scrollToBottomIfNeeded() {
56+
val now = System.currentTimeMillis()
57+
// 防抖:100ms 内只执行一次
58+
if (now - lastScrollTime < 100) return
59+
lastScrollTime = now
60+
61+
if (shouldAutoScroll) {
62+
coroutineScope.launch {
63+
val lastIndex = messages.size
64+
listState.scrollToItem(lastIndex)
4365
}
44-
listState.animateScrollToItem(targetIndex)
66+
}
67+
}
68+
69+
// 监听滚动状态,检测用户是否手动滚动
70+
LaunchedEffect(listState.isScrollInProgress) {
71+
if (listState.isScrollInProgress) {
72+
// 用户正在滚动
73+
val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
74+
val totalItems = listState.layoutInfo.totalItemsCount
75+
76+
// 如果用户滚动到的位置不是底部附近(倒数第2项以内),认为用户想查看历史
77+
userScrolledAway = lastVisibleIndex < totalItems - 2
78+
}
79+
}
80+
81+
// 新消息到达时自动滚动(基于消息 ID 变化)
82+
LaunchedEffect(messages.lastOrNull()?.timestamp) {
83+
if (messages.isNotEmpty() && !isLLMProcessing) {
84+
// 新消息完成时,重置用户滚动状态并滚动到底部
85+
userScrolledAway = false
86+
listState.animateScrollToItem(messages.size - 1)
87+
}
88+
}
89+
90+
// 监听内容变化(每50字符或每新行)
91+
LaunchedEffect(currentOutput) {
92+
if (shouldAutoScroll) {
93+
val lineCount = currentOutput.count { it == '\n' }
94+
val chunkIndex = currentOutput.length / 100 // 改为每100字符,减少频率
95+
val contentSignature = lineCount + chunkIndex
96+
97+
// 延迟执行,避免在布局完成前滚动
98+
delay(100)
99+
scrollToBottomIfNeeded()
100+
}
101+
}
102+
103+
// 流式输出开始时,重置状态
104+
LaunchedEffect(isLLMProcessing) {
105+
if (isLLMProcessing) {
106+
userScrolledAway = false
45107
}
46108
}
47109

@@ -69,7 +131,13 @@ fun MessageList(
69131
// 显示正在生成的 AI 响应(只在流式输出时显示)
70132
if (isLLMProcessing && currentOutput.isNotEmpty()) {
71133
item(key = "streaming") {
72-
StreamingMessageItem(content = currentOutput)
134+
StreamingMessageItem(
135+
content = currentOutput,
136+
onContentUpdate = { blockCount ->
137+
// 块数量变化时触发滚动
138+
scrollToBottomIfNeeded()
139+
}
140+
)
73141
}
74142
}
75143
}
@@ -132,7 +200,10 @@ private fun MessageItem(message: Message) {
132200
* 流式输出消息项
133201
*/
134202
@Composable
135-
private fun StreamingMessageItem(content: String) {
203+
private fun StreamingMessageItem(
204+
content: String,
205+
onContentUpdate: (blockCount: Int) -> Unit = {}
206+
) {
136207
Column(
137208
modifier = Modifier.fillMaxWidth()
138209
) {
@@ -166,6 +237,7 @@ private fun StreamingMessageItem(content: String) {
166237
SketchRenderer.RenderResponse(
167238
content = content,
168239
isComplete = false,
240+
onContentUpdate = onContentUpdate,
169241
modifier = Modifier.fillMaxWidth()
170242
)
171243
}

mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/chat/TopBarMenu.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,3 +227,4 @@ fun TopBarMenu(
227227
}
228228
}
229229

230+
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package cc.unitmesh.devins.ui.compose.sketch
2+
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.ui.Modifier
5+
6+
/**
7+
* 内容渲染器统一接口
8+
*
9+
* 所有渲染器(TextBlockRenderer, CodeBlockRenderer, DiffSketchRenderer 等)
10+
* 都应该实现这个接口,以便:
11+
* 1. 统一管理渲染逻辑
12+
* 2. 支持渲染进度回调
13+
* 3. 支持流式渲染状态
14+
*/
15+
interface ContentRenderer {
16+
/**
17+
* 渲染内容
18+
*
19+
* @param content 要渲染的内容
20+
* @param isComplete 内容是否完整(流式输出时为 false)
21+
* @param onRenderUpdate 渲染更新回调,传递渲染元数据
22+
* @param modifier Compose Modifier
23+
*/
24+
@Composable
25+
fun Render(
26+
content: String,
27+
isComplete: Boolean = true,
28+
onRenderUpdate: ((RenderMetadata) -> Unit)? = null,
29+
modifier: Modifier = Modifier
30+
)
31+
}
32+
33+
/**
34+
* 渲染元数据
35+
*
36+
* 用于传递渲染器的状态信息给外层组件
37+
*/
38+
data class RenderMetadata(
39+
/**
40+
* 渲染的块/元素数量
41+
* 例如:CodeFence 解析出的块数量
42+
*/
43+
val blockCount: Int = 0,
44+
45+
/**
46+
* 估计的内容高度(可选)
47+
* 单位:像素,用于精确滚动控制
48+
*/
49+
val estimatedHeight: Int? = null,
50+
51+
/**
52+
* 最后一个块的类型(可选)
53+
* 例如:"markdown", "code", "diff"
54+
*/
55+
val lastBlockType: String? = null,
56+
57+
/**
58+
* 是否包含可展开内容(可选)
59+
* 例如:长代码块、大型 diff
60+
*/
61+
val hasExpandableContent: Boolean = false,
62+
63+
/**
64+
* 自定义元数据(可选)
65+
* 用于特定渲染器的扩展信息
66+
*/
67+
val customData: Map<String, Any>? = null
68+
)
69+
70+
/**
71+
* 简单的渲染器基类
72+
* 提供默认实现,子类可以选择性覆盖
73+
*/
74+
abstract class BaseContentRenderer : ContentRenderer {
75+
/**
76+
* 通知渲染更新的辅助方法
77+
*/
78+
protected fun notifyRenderUpdate(
79+
onRenderUpdate: ((RenderMetadata) -> Unit)?,
80+
blockCount: Int = 0,
81+
lastBlockType: String? = null
82+
) {
83+
onRenderUpdate?.invoke(
84+
RenderMetadata(
85+
blockCount = blockCount,
86+
lastBlockType = lastBlockType
87+
)
88+
)
89+
}
90+
}
91+

0 commit comments

Comments
 (0)