Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/architecture/peer-device-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,25 @@ records and responses from a device that is no longer rendered. Desktop
`RelaySessionHistory` owns the subscription across initial loading, realtime
delivery and older-page prefetch.

Native mobile history keeps these record-page boundaries; a page is not a
complete conversation turn. The initial replay and each older-history request
reduce all received records before publishing one transcript projection. A turn
split across pages may gain text or tools on a later read; that is normal and
must preserve the existing reading position. Realtime updates remain incremental.

The loading indicator covers the RPC and delivery to the reducer. Kotlin's
buffered transport waits for downstream consumption before reporting caught-up
or completing an older-page request; enqueueing records is not completion.
Kotlin uses local history-start/ready events and HarmonyOS uses local replay
callbacks to suppress intermediate projections. These are client-internal
boundaries, not additions to the `read_stream` wire format. A failed multi-page
read commits only the fully received pages and reports failure; a later retry
continues from the durable record cursor. Session changes fence stale delivery.
Native timelines retain visible message anchors on prepend, allow at most one
automatic request per deliberate drag, and do not queue gestures made while
loading. Layout, anchor correction and released-finger overscroll cannot request
another page.

Version skew is negotiated, not assumed. Hosts advertise `host_stream_v1` in
their handshake `capabilities`; a controller that does not see it reports the
host as too old instead of sending `read_stream`, and a host that receives the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,6 @@ class ChatMessageBubbleTest {
tools = emptyList(),
blocks = blocks,
streaming = streaming,
pending = false,
typing = typing,
showRetry = showRetry,
error = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeDown
import androidx.compose.ui.test.performScrollToIndex
import com.openbitfun.mobile.core.feature.session.HistoryLoadState
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.test.platform.app.InstrumentationRegistry
Expand Down Expand Up @@ -234,6 +236,38 @@ class ConversationViewTest {
.assertDoesNotExist()
}

@Test
fun historyPrependKeepsVisibleMessagesAndRepeatedDragsDoNotQueueRequests() {
val rows = mutableStateOf((0..5).map { assistantRow("history-$it", "history-$it") })
val loading = mutableStateOf(HistoryLoadState.IDLE)
var requests = 0
composeRule.setContent {
OpenBitFunTheme(dark = false) {
TimelineForTest(rows.value, hasMoreMessages = true, historyLoadState = loading.value,
onLoadOlder = { requests++; loading.value = HistoryLoadState.LOADING })
}
}
val list = composeRule.onNodeWithTag(CONVERSATION_LIST_TEST_TAG)
repeat(3) { list.performTouchInput { swipeDown() }; composeRule.waitForIdle() }
composeRule.runOnIdle { assertEquals(1, requests) }
val before = composeRule.onNodeWithText("history-0").getUnclippedBoundsInRoot().top
composeRule.runOnIdle {
rows.value = (-12..-1).map { assistantRow("history-$it", "history-$it") } + rows.value
loading.value = HistoryLoadState.IDLE
}
composeRule.waitForIdle()
val after = composeRule.onNodeWithText("history-0").getUnclippedBoundsInRoot().top
assertTrue("Prepending moved the visible row from $before to $after", kotlin.math.abs((after - before).value) < 4)
composeRule.runOnIdle { assertEquals(1, requests) }
// Moving the list without a gesture must not fetch another page.
list.performScrollToIndex(0)
composeRule.waitForIdle()
composeRule.runOnIdle { assertEquals(1, requests) }
list.performTouchInput { swipeDown() }
composeRule.waitForIdle()
composeRule.runOnIdle { assertEquals(2, requests) }
}

@Test
fun withLoadOlderHeaderStreamingGrowthStaysOnTheRealTail() {
val rows = mutableStateOf(
Expand Down Expand Up @@ -487,13 +521,16 @@ class ConversationViewTest {
hasMoreMessages: Boolean = false,
topInset: Dp = 0.dp,
bottomInset: Dp = 0.dp,
historyLoadState: HistoryLoadState = HistoryLoadState.IDLE,
onLoadOlder: () -> Unit = {},
) {
ConversationTimelineView(
rows = rows,
hasMoreMessages = hasMoreMessages,
topInset = topInset,
bottomInset = bottomInset,
onLoadOlder = {},
historyLoadState = historyLoadState,
onLoadOlder = onLoadOlder,
enabled = true,
onApproveTool = { _, _ -> },
onRejectTool = { _, _ -> },
Expand Down Expand Up @@ -525,7 +562,6 @@ class ConversationViewTest {
blocks = emptyList(),
streaming = streaming,
typing = false,
pending = false,
showRetry = false,
error = null,
live = false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.openbitfun.mobile.app.R
import com.openbitfun.mobile.app.ui.chat.message.ChatCaption
import com.openbitfun.mobile.app.ui.chat.message.ChatMessageRetryAction
import com.openbitfun.mobile.app.ui.chat.message.ChatTypingDots
import com.openbitfun.mobile.app.ui.chat.message.ChatUserMessageBubble
Expand Down Expand Up @@ -90,8 +89,8 @@ internal fun ChatMessageBubble(
MessageImageGallery(images = row.images, userStyle = false)
}

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

row.typing -> ChatTypingDots(Modifier)
row.pending -> ChatCaption(stringResource(R.string.chat_pending), error = false)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -759,7 +759,7 @@ private fun AttachmentStrip(
) {
items(images, key = { it.id }) { image ->
Box(modifier = Modifier.size(64.dp)) {
val bitmap = remember(image.dataUrl) { decodeInlineImage(image.dataUrl) }
val bitmap = rememberInlineImage(image.dataUrl)
if (bitmap != null) {
Image(
bitmap = bitmap.asImageBitmap(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
Expand All @@ -40,6 +41,7 @@ import com.openbitfun.mobile.app.R
import com.openbitfun.mobile.core.feature.session.ConversationRow
import com.openbitfun.mobile.core.feature.session.QuestionAnswer
import com.openbitfun.mobile.core.feature.workspace.RemoteFileDownloadUiState
import kotlinx.coroutines.flow.distinctUntilChanged

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

/** One automatic page per deliberate drag; layout and bounce cannot re-arm it. */
internal class HistoryPageArrivalTracker {
private var consumed = true
fun beginGesture() { consumed = false }
fun arrived(atStart: Boolean): Boolean {
if (!atStart || consumed) return false
consumed = true
return true
}
fun cancelArrival() { consumed = true }
}

/** Timeline renderer over feature-owned presentation rows; session routing stays above it. */
@Composable
internal fun ConversationTimelineView(
Expand Down Expand Up @@ -98,11 +112,12 @@ internal fun ConversationTimelineView(
var stickToBottom by rememberSaveable { mutableStateOf(true) }
val atBottom by remember(listState) { derivedStateOf { !listState.canScrollForward } }

val historyArrival = remember { HistoryPageArrivalTracker() }
var userDragging by remember { mutableStateOf(false) }
LaunchedEffect(listState.interactionSource) {
listState.interactionSource.interactions.collect { interaction ->
when (interaction) {
is DragInteraction.Start -> { userDragging = true; stickToBottom = false }
is DragInteraction.Start -> { historyArrival.beginGesture(); userDragging = true; stickToBottom = false }
is DragInteraction.Stop, is DragInteraction.Cancel -> userDragging = false
}
}
Expand All @@ -128,6 +143,31 @@ internal fun ConversationTimelineView(
}
}

// Reaching the start of the loaded transcript asks for the next page by
// itself; the header stays as the loading and retry state. Busy gestures
// are consumed so completion cannot silently queue another page.
val canRequestOlder by rememberUpdatedState(
enabled && hasMoreMessages && historyLoadState != HistoryLoadState.LOADING
&& historyLoadState != HistoryLoadState.FAILED,
)
val requestOlder by rememberUpdatedState {
stickToBottom = false
onLoadOlder()
}
LaunchedEffect(listState, hasMoreMessages) {
// Index zero is the "load older messages" header, so seeing it is the
// reader standing at the start of what is loaded. Following the tail is
// excluded: a first page that does not fill the pane is at the start
// without the reader having gone there, and asking from there would
// fight the initial tail scroll.
snapshotFlow { userDragging && listState.firstVisibleItemIndex == 0 && !stickToBottom }
.distinctUntilChanged()
.collect { readerReachedStart ->
if (!historyArrival.arrived(readerReachedStart)) return@collect
if (canRequestOlder) requestOlder()
}
}

Box(modifier = modifier) {
LazyColumn(
state = listState,
Expand All @@ -144,7 +184,7 @@ internal fun ConversationTimelineView(
item(key = "load-older-messages") {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
TextButton(
onClick = { stickToBottom = false; onLoadOlder() },
onClick = { historyArrival.cancelArrival(); stickToBottom = false; onLoadOlder() },
enabled = enabled && historyLoadState != HistoryLoadState.LOADING,
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurfaceVariant),
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,16 @@ internal fun decodeInlineImage(dataUrl: String): Bitmap? {
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
}.getOrNull()
}

/** Pure projection work belongs off the Compose/UI thread. Key changes cancel
* the old delivery, so a reused row cannot receive another message's bitmap. */
@androidx.compose.runtime.Composable
internal fun rememberInlineImage(dataUrl: String): Bitmap? {
val state = androidx.compose.runtime.produceState<Bitmap?>(null, dataUrl) {
value = null
value = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Default) {
decodeInlineImage(dataUrl)
}
}
return state.value
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.openbitfun.mobile.app.R
import com.openbitfun.mobile.app.ui.chat.decodeInlineImage
import com.openbitfun.mobile.app.ui.chat.rememberInlineImage
import com.openbitfun.mobile.app.ui.chat.tool.PillButton
import com.openbitfun.mobile.core.feature.session.ConversationImage

Expand Down Expand Up @@ -198,7 +198,7 @@ internal fun MessageImageGallery(
internal fun ChatAttachedImage(image: ConversationImage, userStyle: Boolean = false) {
// Anything the relay did not carry inline shows as a name rather than a
// broken frame; see [decodeInlineImage].
val bitmap = remember(image.dataUrl) { decodeInlineImage(image.dataUrl) }
val bitmap = rememberInlineImage(image.dataUrl)
val imageSize = if (userStyle) 112.dp else 92.dp
val shape = RoundedCornerShape(if (userStyle) 12.dp else 14.dp)
if (bitmap != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,10 +229,18 @@ internal fun FilePreviewSurface(
}

is RemoteFilePreviewUiState.Image -> {
val bitmap = remember(preview.bytes) {
BitmapFactory.decodeByteArray(preview.bytes, 0, preview.bytes.size)
val decoded by androidx.compose.runtime.produceState<Pair<Boolean, android.graphics.Bitmap?>>(
initialValue = false to null, key1 = preview.bytes,
) {
value = false to null
value = true to kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Default) {
BitmapFactory.decodeByteArray(preview.bytes, 0, preview.bytes.size)
}
}
if (bitmap != null) {
val bitmap = decoded.second
if (!decoded.first) {
CenteredState(spacing = 12, gutter = 32) { CircularProgressIndicator() }
} else if (bitmap != null) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = preview.name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,6 @@
<string name="chat_empty_title">还没有消息</string>
<string name="chat_empty_hint">可以就这个工作区向智能体提问。</string>
<string name="chat_empty_loading">正在加载对话…</string>
<string name="chat_pending">发送中…</string>
<string name="chat_send_failed">发送失败,请检查连接后重试</string>
<string name="chat_reply_interrupted">回复被中断。</string>
<string name="chat_retry">重试</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,6 @@
<string name="chat_empty_title">No messages yet</string>
<string name="chat_empty_hint">Ask the agent anything about this workspace.</string>
<string name="chat_empty_loading">Loading conversation…</string>
<string name="chat_pending">Sending…</string>
<string name="chat_send_failed">Send failed. Check the connection and retry.</string>
<!-- The agent's reply started and stopped partway; the user's never left the device. -->
<string name="chat_reply_interrupted">Reply interrupted.</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.openbitfun.mobile.app.ui.chat

import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class HistoryPageArrivalTrackerTest {
@Test fun layoutCannotRequestAndBounceCannotRearmTheSameGesture() {
val tracker = HistoryPageArrivalTracker()
assertFalse(tracker.arrived(true))
tracker.beginGesture()
assertFalse(tracker.arrived(false))
assertTrue(tracker.arrived(true))
repeat(10) {
assertFalse(tracker.arrived(false))
assertFalse(tracker.arrived(true))
}
tracker.beginGesture()
assertTrue(tracker.arrived(true))
tracker.cancelArrival()
assertFalse(tracker.arrived(true))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -337,10 +337,11 @@ export struct ChatMessageBubble {
}

@Builder
SubagentGroup(entry: ConversationUiMessageItem, _path: string, activeScope: boolean = false) {
SubagentGroup(entry: ConversationUiMessageItem, path: string, activeScope: boolean = false) {
SubagentTaskCard({
interactionMailbox: this.interactionMailbox,
title: this.subagentTitle(entry),
path: path,
status: entry.tool?.status || (activeScope ? 'running' : 'completed'),
items: entry.subItems || [],
renderRevision: this.renderRevision,
Expand Down
Loading
Loading