Skip to content

Commit 791173c

Browse files
authored
Merge pull request #3177 from wgqqqqq/wgq/mobile-history-paging
fix(mobile): page native remote history until the reader sees an older turn
2 parents 2c2cb90 + fc6d7df commit 791173c

65 files changed

Lines changed: 2483 additions & 444 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/architecture/peer-device-mode.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,25 @@ records and responses from a device that is no longer rendered. Desktop
125125
`RelaySessionHistory` owns the subscription across initial loading, realtime
126126
delivery and older-page prefetch.
127127

128+
Native mobile history keeps these record-page boundaries; a page is not a
129+
complete conversation turn. The initial replay and each older-history request
130+
reduce all received records before publishing one transcript projection. A turn
131+
split across pages may gain text or tools on a later read; that is normal and
132+
must preserve the existing reading position. Realtime updates remain incremental.
133+
134+
The loading indicator covers the RPC and delivery to the reducer. Kotlin's
135+
buffered transport waits for downstream consumption before reporting caught-up
136+
or completing an older-page request; enqueueing records is not completion.
137+
Kotlin uses local history-start/ready events and HarmonyOS uses local replay
138+
callbacks to suppress intermediate projections. These are client-internal
139+
boundaries, not additions to the `read_stream` wire format. A failed multi-page
140+
read commits only the fully received pages and reports failure; a later retry
141+
continues from the durable record cursor. Session changes fence stale delivery.
142+
Native timelines retain visible message anchors on prepend, allow at most one
143+
automatic request per deliberate drag, and do not queue gestures made while
144+
loading. Layout, anchor correction and released-finger overscroll cannot request
145+
another page.
146+
128147
Version skew is negotiated, not assumed. Hosts advertise `host_stream_v1` in
129148
their handshake `capabilities`; a controller that does not see it reports the
130149
host as too old instead of sending `read_stream`, and a host that receives the

‎src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ChatMessageBubbleTest.kt‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,6 @@ class ChatMessageBubbleTest {
258258
tools = emptyList(),
259259
blocks = blocks,
260260
streaming = streaming,
261-
pending = false,
262261
typing = typing,
263262
showRetry = showRetry,
264263
error = null,

‎src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ConversationViewTest.kt‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import androidx.compose.ui.test.performClick
2525
import androidx.compose.ui.test.performTextReplacement
2626
import androidx.compose.ui.test.performTouchInput
2727
import androidx.compose.ui.test.swipeDown
28+
import androidx.compose.ui.test.performScrollToIndex
29+
import com.openbitfun.mobile.core.feature.session.HistoryLoadState
2830
import androidx.compose.ui.unit.Dp
2931
import androidx.compose.ui.unit.dp
3032
import androidx.test.platform.app.InstrumentationRegistry
@@ -234,6 +236,38 @@ class ConversationViewTest {
234236
.assertDoesNotExist()
235237
}
236238

239+
@Test
240+
fun historyPrependKeepsVisibleMessagesAndRepeatedDragsDoNotQueueRequests() {
241+
val rows = mutableStateOf((0..5).map { assistantRow("history-$it", "history-$it") })
242+
val loading = mutableStateOf(HistoryLoadState.IDLE)
243+
var requests = 0
244+
composeRule.setContent {
245+
OpenBitFunTheme(dark = false) {
246+
TimelineForTest(rows.value, hasMoreMessages = true, historyLoadState = loading.value,
247+
onLoadOlder = { requests++; loading.value = HistoryLoadState.LOADING })
248+
}
249+
}
250+
val list = composeRule.onNodeWithTag(CONVERSATION_LIST_TEST_TAG)
251+
repeat(3) { list.performTouchInput { swipeDown() }; composeRule.waitForIdle() }
252+
composeRule.runOnIdle { assertEquals(1, requests) }
253+
val before = composeRule.onNodeWithText("history-0").getUnclippedBoundsInRoot().top
254+
composeRule.runOnIdle {
255+
rows.value = (-12..-1).map { assistantRow("history-$it", "history-$it") } + rows.value
256+
loading.value = HistoryLoadState.IDLE
257+
}
258+
composeRule.waitForIdle()
259+
val after = composeRule.onNodeWithText("history-0").getUnclippedBoundsInRoot().top
260+
assertTrue("Prepending moved the visible row from $before to $after", kotlin.math.abs((after - before).value) < 4)
261+
composeRule.runOnIdle { assertEquals(1, requests) }
262+
// Moving the list without a gesture must not fetch another page.
263+
list.performScrollToIndex(0)
264+
composeRule.waitForIdle()
265+
composeRule.runOnIdle { assertEquals(1, requests) }
266+
list.performTouchInput { swipeDown() }
267+
composeRule.waitForIdle()
268+
composeRule.runOnIdle { assertEquals(2, requests) }
269+
}
270+
237271
@Test
238272
fun withLoadOlderHeaderStreamingGrowthStaysOnTheRealTail() {
239273
val rows = mutableStateOf(
@@ -487,13 +521,16 @@ class ConversationViewTest {
487521
hasMoreMessages: Boolean = false,
488522
topInset: Dp = 0.dp,
489523
bottomInset: Dp = 0.dp,
524+
historyLoadState: HistoryLoadState = HistoryLoadState.IDLE,
525+
onLoadOlder: () -> Unit = {},
490526
) {
491527
ConversationTimelineView(
492528
rows = rows,
493529
hasMoreMessages = hasMoreMessages,
494530
topInset = topInset,
495531
bottomInset = bottomInset,
496-
onLoadOlder = {},
532+
historyLoadState = historyLoadState,
533+
onLoadOlder = onLoadOlder,
497534
enabled = true,
498535
onApproveTool = { _, _ -> },
499536
onRejectTool = { _, _ -> },
@@ -525,7 +562,6 @@ class ConversationViewTest {
525562
blocks = emptyList(),
526563
streaming = streaming,
527564
typing = false,
528-
pending = false,
529565
showRetry = false,
530566
error = null,
531567
live = false,

‎src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ChatMessageBubble.kt‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import androidx.compose.ui.platform.testTag
1313
import androidx.compose.ui.res.stringResource
1414
import androidx.compose.ui.unit.dp
1515
import com.openbitfun.mobile.app.R
16-
import com.openbitfun.mobile.app.ui.chat.message.ChatCaption
1716
import com.openbitfun.mobile.app.ui.chat.message.ChatMessageRetryAction
1817
import com.openbitfun.mobile.app.ui.chat.message.ChatTypingDots
1918
import com.openbitfun.mobile.app.ui.chat.message.ChatUserMessageBubble
@@ -90,8 +89,8 @@ internal fun ChatMessageBubble(
9089
MessageImageGallery(images = row.images, userStyle = false)
9190
}
9291

93-
// Three mutually exclusive footnotes about delivery, in the order they
94-
// can happen: nothing has arrived yet, sent but unacknowledged, refused.
92+
// Two mutually exclusive footnotes about delivery: nothing has arrived
93+
// yet, or the send was refused.
9594
when {
9695
row.showRetry -> ChatMessageRetryAction(
9796
fromUser = fromUser,
@@ -101,7 +100,6 @@ internal fun ChatMessageBubble(
101100
)
102101

103102
row.typing -> ChatTypingDots(Modifier)
104-
row.pending -> ChatCaption(stringResource(R.string.chat_pending), error = false)
105103
}
106104
}
107105
}

‎src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ComposerBar.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -759,7 +759,7 @@ private fun AttachmentStrip(
759759
) {
760760
items(images, key = { it.id }) { image ->
761761
Box(modifier = Modifier.size(64.dp)) {
762-
val bitmap = remember(image.dataUrl) { decodeInlineImage(image.dataUrl) }
762+
val bitmap = rememberInlineImage(image.dataUrl)
763763
if (bitmap != null) {
764764
Image(
765765
bitmap = bitmap.asImageBitmap(),

‎src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationTimelineView.kt‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import androidx.compose.runtime.derivedStateOf
2626
import androidx.compose.runtime.getValue
2727
import androidx.compose.runtime.mutableStateOf
2828
import androidx.compose.runtime.remember
29+
import androidx.compose.runtime.rememberUpdatedState
2930
import androidx.compose.runtime.saveable.rememberSaveable
3031
import androidx.compose.runtime.setValue
3132
import androidx.compose.runtime.snapshotFlow
@@ -40,6 +41,7 @@ import com.openbitfun.mobile.app.R
4041
import com.openbitfun.mobile.core.feature.session.ConversationRow
4142
import com.openbitfun.mobile.core.feature.session.QuestionAnswer
4243
import com.openbitfun.mobile.core.feature.workspace.RemoteFileDownloadUiState
44+
import kotlinx.coroutines.flow.distinctUntilChanged
4345

4446
/** Pure decisions for keeping a forward timeline at its visual tail. */
4547
internal object ConversationScrollPolicy {
@@ -65,6 +67,18 @@ internal object ConversationScrollPolicy {
6567
if (hasMoreMessages) rowCount else (rowCount - 1).coerceAtLeast(0)
6668
}
6769

70+
/** One automatic page per deliberate drag; layout and bounce cannot re-arm it. */
71+
internal class HistoryPageArrivalTracker {
72+
private var consumed = true
73+
fun beginGesture() { consumed = false }
74+
fun arrived(atStart: Boolean): Boolean {
75+
if (!atStart || consumed) return false
76+
consumed = true
77+
return true
78+
}
79+
fun cancelArrival() { consumed = true }
80+
}
81+
6882
/** Timeline renderer over feature-owned presentation rows; session routing stays above it. */
6983
@Composable
7084
internal fun ConversationTimelineView(
@@ -98,11 +112,12 @@ internal fun ConversationTimelineView(
98112
var stickToBottom by rememberSaveable { mutableStateOf(true) }
99113
val atBottom by remember(listState) { derivedStateOf { !listState.canScrollForward } }
100114

115+
val historyArrival = remember { HistoryPageArrivalTracker() }
101116
var userDragging by remember { mutableStateOf(false) }
102117
LaunchedEffect(listState.interactionSource) {
103118
listState.interactionSource.interactions.collect { interaction ->
104119
when (interaction) {
105-
is DragInteraction.Start -> { userDragging = true; stickToBottom = false }
120+
is DragInteraction.Start -> { historyArrival.beginGesture(); userDragging = true; stickToBottom = false }
106121
is DragInteraction.Stop, is DragInteraction.Cancel -> userDragging = false
107122
}
108123
}
@@ -128,6 +143,31 @@ internal fun ConversationTimelineView(
128143
}
129144
}
130145

146+
// Reaching the start of the loaded transcript asks for the next page by
147+
// itself; the header stays as the loading and retry state. Busy gestures
148+
// are consumed so completion cannot silently queue another page.
149+
val canRequestOlder by rememberUpdatedState(
150+
enabled && hasMoreMessages && historyLoadState != HistoryLoadState.LOADING
151+
&& historyLoadState != HistoryLoadState.FAILED,
152+
)
153+
val requestOlder by rememberUpdatedState {
154+
stickToBottom = false
155+
onLoadOlder()
156+
}
157+
LaunchedEffect(listState, hasMoreMessages) {
158+
// Index zero is the "load older messages" header, so seeing it is the
159+
// reader standing at the start of what is loaded. Following the tail is
160+
// excluded: a first page that does not fill the pane is at the start
161+
// without the reader having gone there, and asking from there would
162+
// fight the initial tail scroll.
163+
snapshotFlow { userDragging && listState.firstVisibleItemIndex == 0 && !stickToBottom }
164+
.distinctUntilChanged()
165+
.collect { readerReachedStart ->
166+
if (!historyArrival.arrived(readerReachedStart)) return@collect
167+
if (canRequestOlder) requestOlder()
168+
}
169+
}
170+
131171
Box(modifier = modifier) {
132172
LazyColumn(
133173
state = listState,
@@ -144,7 +184,7 @@ internal fun ConversationTimelineView(
144184
item(key = "load-older-messages") {
145185
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
146186
TextButton(
147-
onClick = { stickToBottom = false; onLoadOlder() },
187+
onClick = { historyArrival.cancelArrival(); stickToBottom = false; onLoadOlder() },
148188
enabled = enabled && historyLoadState != HistoryLoadState.LOADING,
149189
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurfaceVariant),
150190
) {

‎src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/InlineImage.kt‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,16 @@ internal fun decodeInlineImage(dataUrl: String): Bitmap? {
2222
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
2323
}.getOrNull()
2424
}
25+
26+
/** Pure projection work belongs off the Compose/UI thread. Key changes cancel
27+
* the old delivery, so a reused row cannot receive another message's bitmap. */
28+
@androidx.compose.runtime.Composable
29+
internal fun rememberInlineImage(dataUrl: String): Bitmap? {
30+
val state = androidx.compose.runtime.produceState<Bitmap?>(null, dataUrl) {
31+
value = null
32+
value = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Default) {
33+
decodeInlineImage(dataUrl)
34+
}
35+
}
36+
return state.value
37+
}

‎src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/message/ChatMessageChrome.kt‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import androidx.compose.ui.res.stringResource
4040
import androidx.compose.ui.unit.dp
4141
import androidx.compose.ui.unit.sp
4242
import com.openbitfun.mobile.app.R
43-
import com.openbitfun.mobile.app.ui.chat.decodeInlineImage
43+
import com.openbitfun.mobile.app.ui.chat.rememberInlineImage
4444
import com.openbitfun.mobile.app.ui.chat.tool.PillButton
4545
import com.openbitfun.mobile.core.feature.session.ConversationImage
4646

@@ -198,7 +198,7 @@ internal fun MessageImageGallery(
198198
internal fun ChatAttachedImage(image: ConversationImage, userStyle: Boolean = false) {
199199
// Anything the relay did not carry inline shows as a name rather than a
200200
// broken frame; see [decodeInlineImage].
201-
val bitmap = remember(image.dataUrl) { decodeInlineImage(image.dataUrl) }
201+
val bitmap = rememberInlineImage(image.dataUrl)
202202
val imageSize = if (userStyle) 112.dp else 92.dp
203203
val shape = RoundedCornerShape(if (userStyle) 12.dp else 14.dp)
204204
if (bitmap != null) {

‎src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/FilePreviewSurface.kt‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,10 +229,18 @@ internal fun FilePreviewSurface(
229229
}
230230

231231
is RemoteFilePreviewUiState.Image -> {
232-
val bitmap = remember(preview.bytes) {
233-
BitmapFactory.decodeByteArray(preview.bytes, 0, preview.bytes.size)
232+
val decoded by androidx.compose.runtime.produceState<Pair<Boolean, android.graphics.Bitmap?>>(
233+
initialValue = false to null, key1 = preview.bytes,
234+
) {
235+
value = false to null
236+
value = true to kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Default) {
237+
BitmapFactory.decodeByteArray(preview.bytes, 0, preview.bytes.size)
238+
}
234239
}
235-
if (bitmap != null) {
240+
val bitmap = decoded.second
241+
if (!decoded.first) {
242+
CenteredState(spacing = 12, gutter = 32) { CircularProgressIndicator() }
243+
} else if (bitmap != null) {
236244
Image(
237245
bitmap = bitmap.asImageBitmap(),
238246
contentDescription = preview.name,

‎src/apps/mobile/android/app/src/main/res/values-zh/strings.xml‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,6 @@
369369
<string name="chat_empty_title">还没有消息</string>
370370
<string name="chat_empty_hint">可以就这个工作区向智能体提问。</string>
371371
<string name="chat_empty_loading">正在加载对话…</string>
372-
<string name="chat_pending">发送中…</string>
373372
<string name="chat_send_failed">发送失败,请检查连接后重试</string>
374373
<string name="chat_reply_interrupted">回复被中断。</string>
375374
<string name="chat_retry">重试</string>

0 commit comments

Comments
 (0)