From d3b8488941c02b8b1bfa6cda7f08ad33ae44d706 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Mon, 21 Sep 2026 19:09:23 +0800 Subject: [PATCH 1/2] fix(mobile): page native remote history until the reader sees an older turn Host pages are cut by sequence, and a record's sequence is the moment it was last updated, so one long turn owns every record it produced. Reading a single page per request could therefore deliver nothing but more records of the turn already on screen: the timeline reported "history loaded" while the transcript above stayed identical, and the newest turn of a long session can hold several pages by itself. One request now walks up to four pages and stops as soon as it delivers a turn the transcript did not have, the host runs out, or the budget is spent; the next gesture continues from where it stopped. The request reports one settled history state instead of one per page, and the loading indicator covers the whole walk: the buffered Kotlin transport waits for downstream consumption before it reports caught-up or completes a request, so enqueueing records no longer reads as completion. A request is a user gesture waiting outside the flow, so the lane owes every one an answer: a lane that closes now fails queued requests instead of leaving their callers suspended for the life of the app. Wakes coalesce before the lane serves them, so a hint burst costs one catch-up read and a history request that arrived during the burst is served in that same pass instead of behind one refresh per hint. The burst no longer rewrites the transcript per record either. Writes are deferred until a page settles, a write reuses the rows it wrote last time and appends only what changed, and a turn renders once until its records or controls change. Disconnect, session switch and delete drop the cached rows. iOS, Android and HarmonyOS keep the reading position while paging: one automatic page per deliberate drag, no request queued while loading, anchors preserved on prepend, and no request from layout, anchor correction or released-finger overscroll. iOS also gains the composer dismiss gesture, and subagent children that arrive nested and again flat are folded once instead of drawn twice. A running-input acknowledgement no longer consumes the newly submitted bubble through turn-based deduplication. docs/architecture/peer-device-mode.md records the record-page, delivery and timeline boundaries. These are client-internal boundaries, not additions to the read_stream wire format. --- docs/architecture/peer-device-mode.md | 19 ++ .../mobile/app/ChatMessageBubbleTest.kt | 1 - .../mobile/app/ConversationViewTest.kt | 40 +++- .../mobile/app/ui/chat/ChatMessageBubble.kt | 6 +- .../mobile/app/ui/chat/ComposerBar.kt | 2 +- .../app/ui/chat/ConversationTimelineView.kt | 44 +++- .../mobile/app/ui/chat/InlineImage.kt | 13 ++ .../app/ui/chat/message/ChatMessageChrome.kt | 4 +- .../app/ui/remote/FilePreviewSurface.kt | 14 +- .../app/src/main/res/values-zh/strings.xml | 1 - .../app/src/main/res/values/strings.xml | 1 - .../ui/chat/HistoryPageArrivalTrackerTest.kt | 23 +++ .../pages/components/ChatMessageBubble.ets | 3 +- .../ets/pages/components/ChatTimeline.ets | 44 +++- .../ets/pages/components/SubagentTaskCard.ets | 29 ++- .../ets/pages/components/ToolStatusList.ets | 6 +- .../policy/ChatMessageStructurePolicy.ets | 150 +++++++++++--- .../policy/HistoryPageArrivalTracker.ets | 11 + .../viewmodel/RemoteTranscriptController.ets | 44 +++- .../ets/services/ChatSessionController.ets | 13 +- .../ets/services/DurableSessionReducer.ets | 116 +++++++---- .../main/ets/services/HostSessionStream.ets | 76 +++++-- .../ConversationPresentationUnit.test.ets | 115 ++++++++++- .../src/test/ConversationStateUnit.test.ets | 4 +- .../tools/tests/history-page-arrival.test.cjs | 29 +++ .../tools/tests/host-stream.test.cjs | 83 +++++++- .../tools/tests/session-record.test.cjs | 96 +++++++++ .../ios/OpenBitFun.xcodeproj/project.pbxproj | 10 +- .../App/MobileLaunchConfiguration.swift | 6 +- .../Features/Chat/ChatTimelineView.swift | 173 ++++++++-------- .../Features/Chat/ComposerBar.swift | 67 ++++--- .../Chat/HistoryPageArrivalTracker.swift | 16 ++ .../Chat/TimelineScrollController.swift | 128 +++++++++--- .../DesignSystem/MobileDesignGallery.swift | 37 +++- .../Features/Shell/MiniAppsView.swift | 25 ++- .../Shell/RemoteFilePreviewView.swift | 6 +- .../ComposerDismissGesture.swift | 23 +++ .../MobileAppModel+GeneralChat.swift | 2 - .../MobileAppModel+RemoteSession.swift | 17 +- .../Infrastructure/RemoteAuthorityGate.swift | 20 ++ .../Models/MobilePresentationModels.swift | 6 +- .../Resources/Localizable.xcstrings | 20 -- .../RemoteCodeSessionSendUITests.swift | 23 +++ .../Testing/ComposerDismissGestureTests.swift | 47 +++++ .../HistoryPageArrivalTrackerTests.swift | 21 ++ .../ios/Testing/MobileProcessGroupTests.swift | 6 +- .../Testing/RemoteAuthorityGateTests.swift | 21 ++ .../ios/Testing/run-pure-swift-tests.sh | 12 ++ .../session/ConversationPresentation.kt | 3 - .../session/MessageBlockPresentation.kt | 88 +++++++- .../feature/session/RemoteSessionStore.kt | 116 ++++++++++- .../feature/session/SessionRecordReplica.kt | 139 +++++++++---- .../session/ConversationPresentationTest.kt | 11 +- .../session/MessageBlockPresentationTest.kt | 99 +++++++++ .../session/RemoteSessionPersistenceTest.kt | 72 ++++++- .../feature/session/RemoteSessionStoreTest.kt | 18 +- .../session/SessionRecordReplicaTest.kt | 77 +++++++ .../core/transport/CloudAccountClient.kt | 62 ++++-- .../mobile/core/transport/HostStream.kt | 162 ++++++++++++--- .../mobile/core/transport/RpcPayload.kt | 8 +- .../core/transport/CloudAccountClientTest.kt | 2 +- .../mobile/core/transport/HostStreamTest.kt | 188 +++++++++++++++++- .../core/transport/RpcProcessingThreadTest.kt | 70 +++++++ src/mobile-web/tests/host-stream.test.mjs | 74 ++++++- src/shared/relay-transport/HostStream.ts | 61 +++++- 65 files changed, 2480 insertions(+), 443 deletions(-) create mode 100644 src/apps/mobile/android/app/src/test/kotlin/com/openbitfun/mobile/app/ui/chat/HistoryPageArrivalTrackerTest.kt create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/HistoryPageArrivalTracker.ets create mode 100644 src/apps/mobile/harmonyos/tools/tests/history-page-arrival.test.cjs create mode 100644 src/apps/mobile/ios/OpenBitFun/Features/Chat/HistoryPageArrivalTracker.swift create mode 100644 src/apps/mobile/ios/OpenBitFun/Infrastructure/ComposerDismissGesture.swift create mode 100644 src/apps/mobile/ios/Testing/ComposerDismissGestureTests.swift create mode 100644 src/apps/mobile/ios/Testing/HistoryPageArrivalTrackerTests.swift create mode 100644 src/apps/mobile/shared/core-transport/src/jvmTest/kotlin/com/openbitfun/mobile/core/transport/RpcProcessingThreadTest.kt diff --git a/docs/architecture/peer-device-mode.md b/docs/architecture/peer-device-mode.md index b8e4b6d2d6..922783b873 100644 --- a/docs/architecture/peer-device-mode.md +++ b/docs/architecture/peer-device-mode.md @@ -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 diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ChatMessageBubbleTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ChatMessageBubbleTest.kt index cbc3dbfd97..c7474206fb 100644 --- a/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ChatMessageBubbleTest.kt +++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ChatMessageBubbleTest.kt @@ -258,7 +258,6 @@ class ChatMessageBubbleTest { tools = emptyList(), blocks = blocks, streaming = streaming, - pending = false, typing = typing, showRetry = showRetry, error = null, diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ConversationViewTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ConversationViewTest.kt index ce2e6c5878..713e7ffe24 100644 --- a/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ConversationViewTest.kt +++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ConversationViewTest.kt @@ -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 @@ -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( @@ -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 = { _, _ -> }, @@ -525,7 +562,6 @@ class ConversationViewTest { blocks = emptyList(), streaming = streaming, typing = false, - pending = false, showRetry = false, error = null, live = false, diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ChatMessageBubble.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ChatMessageBubble.kt index 0081951109..f75c17c4de 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ChatMessageBubble.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ChatMessageBubble.kt @@ -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 @@ -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, @@ -101,7 +100,6 @@ internal fun ChatMessageBubble( ) row.typing -> ChatTypingDots(Modifier) - row.pending -> ChatCaption(stringResource(R.string.chat_pending), error = false) } } } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ComposerBar.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ComposerBar.kt index a21118a81b..e190e30ba9 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ComposerBar.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ComposerBar.kt @@ -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(), diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationTimelineView.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationTimelineView.kt index 3b7c9b7725..492decaf8d 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationTimelineView.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationTimelineView.kt @@ -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 @@ -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 { @@ -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( @@ -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 } } @@ -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, @@ -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), ) { diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/InlineImage.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/InlineImage.kt index a5cc09d4da..666ac94932 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/InlineImage.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/InlineImage.kt @@ -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(null, dataUrl) { + value = null + value = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Default) { + decodeInlineImage(dataUrl) + } + } + return state.value +} diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/message/ChatMessageChrome.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/message/ChatMessageChrome.kt index e24284ee2f..0d04738ff5 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/message/ChatMessageChrome.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/message/ChatMessageChrome.kt @@ -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 @@ -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) { diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/FilePreviewSurface.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/FilePreviewSurface.kt index 3b4a1ec2cb..30b518eb0a 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/FilePreviewSurface.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/FilePreviewSurface.kt @@ -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>( + 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, diff --git a/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml b/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml index ee9f2949c2..c48c6b3859 100644 --- a/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml +++ b/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml @@ -369,7 +369,6 @@ 还没有消息 可以就这个工作区向智能体提问。 正在加载对话… - 发送中… 发送失败,请检查连接后重试 回复被中断。 重试 diff --git a/src/apps/mobile/android/app/src/main/res/values/strings.xml b/src/apps/mobile/android/app/src/main/res/values/strings.xml index 36ea9b07f6..a85ac11907 100644 --- a/src/apps/mobile/android/app/src/main/res/values/strings.xml +++ b/src/apps/mobile/android/app/src/main/res/values/strings.xml @@ -383,7 +383,6 @@ No messages yet Ask the agent anything about this workspace. Loading conversation… - Sending… Send failed. Check the connection and retry. Reply interrupted. diff --git a/src/apps/mobile/android/app/src/test/kotlin/com/openbitfun/mobile/app/ui/chat/HistoryPageArrivalTrackerTest.kt b/src/apps/mobile/android/app/src/test/kotlin/com/openbitfun/mobile/app/ui/chat/HistoryPageArrivalTrackerTest.kt new file mode 100644 index 0000000000..5d8b26a781 --- /dev/null +++ b/src/apps/mobile/android/app/src/test/kotlin/com/openbitfun/mobile/app/ui/chat/HistoryPageArrivalTrackerTest.kt @@ -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)) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets index 17faa90d5d..a002702f8e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets @@ -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, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets index 741bd6b8c3..e39c3e0699 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets @@ -23,6 +23,7 @@ import { STATUS_DANGER, } from './Theme'; import { ChatMessageBubble, ChatUserMessageRow } from './ChatMessageBubble'; +import { HistoryPageArrivalTracker } from '../policy/HistoryPageArrivalTracker'; import { RemoteLogger } from '../../services/RemoteLogger'; /** @@ -115,9 +116,11 @@ struct ChatAssistantTimelineRow { export struct ChatTimeline { @Param interactionMailbox: InteractionMailboxState = new InteractionMailboxState(); private readonly listScroller: Scroller = new Scroller(); + private readonly historyArrival: HistoryPageArrivalTracker = new HistoryPageArrivalTracker(); private followTimerId: number = 0; private followedActiveTurnKey: string = ''; private userScrollActive: boolean = false; + private historyTouchActive: boolean = false; // Whether new content should pull the viewport down with it. True until the // user scrolls up to read back through the transcript: at that point taking // the viewport away from them would be worse than letting the reply grow off @@ -201,7 +204,11 @@ export struct ChatTimeline { .width('100%') .padding({ top: 10, bottom: 10 }) .onClick(() => { - if (!this.historyLoading) this.onLoadOlder(); + if (!this.historyLoading) { + this.historyArrival.cancelArrival(); + this.stickToBottom = false; + this.onLoadOlder(); + } }) } } @@ -338,8 +345,17 @@ export struct ChatTimeline { .stackFromEnd(true) // Keeps the read position when older messages are prepended above. .maintainVisibleContentPosition(true) + .edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true }) .scrollBar(BarState.Off) .cachedCount(4) + .onTouch((event: TouchEvent) => { + if (event.type === TouchType.Down) { + this.historyTouchActive = true; + this.historyArrival.beginGesture(); + } else if (event.type === TouchType.Up || event.type === TouchType.Cancel) { + this.historyTouchActive = false; + } + }) .onWillScroll((_scrollOffset: number, _scrollState: ScrollState, scrollSource: ScrollSource) => { if (this.isUserScrollSource(scrollSource)) { this.userScrollActive = true; @@ -351,6 +367,7 @@ export struct ChatTimeline { } else if (this.userScrollActive) { this.stickToBottom = false; } + this.evaluateHistoryStart(); if (scrollState === ScrollState.Idle) { this.userScrollActive = false; } @@ -434,6 +451,31 @@ export struct ChatTimeline { return ChatTimelineRevisionTracker.itemKey(activeItems[activeItems.length - 1]); } + /** + * Asks for the next history page when the reader arrives at the start. + * + * What counts is the reader's position, not the content's: the list keeps the + * visible content in place while a page is prepended above it, so the arrival + * it answered is spent by it and reaching the start again is a deliberate + * scroll back. A timeline still following the tail is excluded — a first page + * that does not fill the viewport sits at the start without the reader having + * gone there — except while the reader is dragging, where the arrival is + * theirs even if the drag never leaves the end. + */ + private evaluateHistoryStart(): void { + const atStart: boolean = this.listScroller.currentOffset().yOffset <= 0; + const readerOwnsPosition: boolean = this.historyTouchActive && this.userScrollActive; + if (!this.historyArrival.arrived(atStart && readerOwnsPosition)) { + return; + } + if (this.hasMoreMessages && !this.historyLoading && !this.historyFailed) { + this.stickToBottom = false; + this.onLoadOlder(); + return; + } + // A drag made while busy is consumed, not queued for after completion. + } + private requestFollowToBottom(reason: string): void { if (!this.stickToBottom || this.followTimerId !== 0) { return; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SubagentTaskCard.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SubagentTaskCard.ets index 5230524942..22b699bb51 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SubagentTaskCard.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SubagentTaskCard.ets @@ -20,6 +20,7 @@ import { ToolGlyph } from './ToolGlyphs'; export struct SubagentTaskCard { @Param interactionMailbox: InteractionMailboxState = new InteractionMailboxState(); @Param title: string = ''; + @Param path: string = ''; @Param status: string = 'completed'; @Param items: ConversationUiMessageItem[] = []; @Param renderRevision: number = 0; @@ -114,7 +115,7 @@ export struct SubagentTaskCard { status: this.childStatus(index), keepExpandedWhenDone: false, streaming: this.childStatus(index) === 'running', - streamKey: `${this.title}-subagent-thinking-${index}` + streamKey: `${this.processItemKey(index)}-thinking` }) } else if (entry.tool) { ToolStatusList({ @@ -146,14 +147,36 @@ export struct SubagentTaskCard { .width('100%') .alignItems(HorizontalAlign.Start) } - }, (entry: ConversationUiMessageItem, index: number) => - ChatMessageStructurePolicy.structuredItemKey(entry, `subagent-${index}`)) + }, (_entry: ConversationUiMessageItem, index: number) => this.processItemKey(index)) } .width('100%') .padding({ left: 14, top: 6, bottom: 4 }) .border({ width: { left: 1 }, color: LINE }) } + /** + * View key for one process entry, anchored to the Task that owns it. + * + * Counting each kind separately keeps a child's key while siblings of other + * kinds arrive, so a running Task cannot renumber what is already drawn. + */ + private processItemKey(index: number): string { + const items = this.visibleProcessItems(); + const ordinals: Map = new Map(); + for (let cursor = 0; cursor <= index && cursor < items.length; cursor++) { + const kind = this.processItemKind(items[cursor]); + const ordinal = ordinals.get(kind) || 0; + if (cursor === index) return `${this.path}-process-${kind}-${ordinal}`; + ordinals.set(kind, ordinal + 1); + } + return `${this.path}-process-item-${index}`; + } + + private processItemKind(entry: ConversationUiMessageItem): string { + if (ChatMessageStructurePolicy.isThinkingEntry(entry)) return 'thinking'; + return entry.tool ? 'tool' : 'text'; + } + private isRunning(): boolean { const normalized = this.status.toLowerCase(); return normalized === 'running' || normalized === 'active' || normalized === 'preparing' || diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets index 45c0b49482..288541df90 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets @@ -472,7 +472,7 @@ export struct ToolStatusList { const entries: ToolRenderEntry[] = []; ActivityGroupPolicy.weaveRows(groups, parts, showThinking).forEach((row: ActivityRowPlan) => { if (row.kind === 'thinking') { - entries.push(this.thinkingEntry(parts[row.index], row.index)); + entries.push(this.thinkingEntry(parts[row.index])); return; } entries.push(this.entryFromGroup(groups[row.index])); @@ -480,10 +480,10 @@ export struct ToolStatusList { return entries; } - private thinkingEntry(part: ActivityThinkingPart, index: number): ToolRenderEntry { + private thinkingEntry(part: ActivityThinkingPart): ToolRenderEntry { return { type: 'thinking', - key: `thinking-${index}-${part.beforeToolIndex}-${part.streamKey}`, + key: part.streamKey, thinking: part, tools: [], readCount: 0, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ChatMessageStructurePolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ChatMessageStructurePolicy.ets index 008d9f32b8..4e8d52bbe2 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ChatMessageStructurePolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ChatMessageStructurePolicy.ets @@ -61,22 +61,32 @@ export class ChatMessageStructurePolicy { const scopedItems = ChatMessageStructurePolicy.scopeSubagentItems(items); const groups: StructuredRenderGroup[] = []; let toolBuffer: ConversationUiToolStatus[] = []; - let toolStart = 0; let activityItems: ConversationUiMessageItem[] = []; let activityStatuses: string[] = []; let activityStreaming: boolean[] = []; let activityChildScopes: boolean[] = []; - let activityStart = 0; let activityHasThinking = false; const activeItemIndex = activeScope ? ChatMessageStructurePolicy.lastRenderableItemIndex(scopedItems) : -1; + // A key is view identity in ArkUI: changing it discards the node together + // with its expansion state. Counting each kind separately keeps a key while + // siblings of other kinds arrive, so appended output cannot renumber the + // transcript that is already on screen. + const ordinals: Map = new Map(); + const nextPath = (kind: string): string => { + const ordinal = ordinals.get(kind) || 0; + ordinals.set(kind, ordinal + 1); + return `${prefix}-${kind}-${ordinal}`; + }; + const flushTools = (): void => { if (toolBuffer.length === 0) return; + const groupPath = nextPath('tools'); groups.push({ type: 'tool_group', items: [], itemStatuses: [], itemStreaming: [], itemChildActiveScopes: [], tools: toolBuffer, - key: `${prefix}-tools-${toolStart}-${toolBuffer.length}-${ChatMessageStructurePolicy.stableToolHash(toolBuffer)}`, - path: `${prefix}-tools-${toolStart}` + key: `${groupPath}-${ChatMessageStructurePolicy.stableToolHash(toolBuffer)}`, + path: groupPath }); toolBuffer = []; }; @@ -84,29 +94,28 @@ export class ChatMessageStructurePolicy { const flushActivity = (): void => { if (activityItems.length === 0) return; if (!activityHasThinking) { - activityItems.forEach((entry: ConversationUiMessageItem, offset: number) => { - if (entry.tool) { - if (toolBuffer.length === 0) toolStart = activityStart + offset; - toolBuffer.push(entry.tool); - } + activityItems.forEach((entry: ConversationUiMessageItem) => { + if (entry.tool) toolBuffer.push(entry.tool); }); } else if (activityItems.length === 1) { const entry = activityItems[0]; flushTools(); + const itemPath = nextPath('thinking'); groups.push({ type: 'item', items: [entry], itemStatuses: [activityStatuses[0] || ''], itemStreaming: [activityStreaming[0] || false], itemChildActiveScopes: [activityChildScopes[0] || false], tools: [], - key: ChatMessageStructurePolicy.structuredItemKey(entry, `${prefix}-${activityStart}`), - path: `${prefix}-${activityStart}` + key: ChatMessageStructurePolicy.structuredItemKey(entry, itemPath), + path: itemPath }); } else { flushTools(); + const groupPath = nextPath('activity'); groups.push({ type: 'activity_group', items: activityItems, itemStatuses: activityStatuses, itemStreaming: activityStreaming, itemChildActiveScopes: activityChildScopes, tools: [], - key: ChatMessageStructurePolicy.activityGroupKey(activityItems, `${prefix}-activity-${activityStart}`), - path: `${prefix}-activity-${activityStart}` + key: ChatMessageStructurePolicy.activityGroupKey(activityItems, groupPath), + path: groupPath }); } activityItems = []; @@ -121,7 +130,6 @@ export class ChatMessageStructurePolicy { // are Task details, not sibling subagent cards in the parent timeline. if (ChatMessageStructurePolicy.isSubagentInternalTool(entry)) return; if (ChatMessageStructurePolicy.shouldFoldIntoActivityGroup(entry)) { - if (activityItems.length === 0) activityStart = index; activityItems.push(entry); activityStatuses.push(ChatMessageStructurePolicy.itemStatus(entry, index, activeItemIndex, activeScope, messageStatus)); @@ -138,6 +146,7 @@ export class ChatMessageStructurePolicy { // their branch title, children, and lifecycle presentation. if (ChatMessageStructurePolicy.isSubagentEntry(entry)) { flushTools(); + const subagentPath = nextPath('subagent'); groups.push({ type: 'item', items: [entry], itemStatuses: [ChatMessageStructurePolicy.itemStatus(entry, index, activeItemIndex, @@ -145,17 +154,17 @@ export class ChatMessageStructurePolicy { itemStreaming: [ChatMessageStructurePolicy.itemShouldStream(entry, index, activeItemIndex, activeScope)], itemChildActiveScopes: [ChatMessageStructurePolicy.itemChildActiveScope(entry, index, activeItemIndex, activeScope)], - tools: [], key: ChatMessageStructurePolicy.structuredItemKey(entry, `${prefix}-${index}`), - path: `${prefix}-${index}` + tools: [], key: ChatMessageStructurePolicy.structuredItemKey(entry, subagentPath), + path: subagentPath }); return; } if (entry.tool) { - if (toolBuffer.length === 0) toolStart = index; toolBuffer.push(entry.tool); return; } flushTools(); + const itemPath = nextPath('item'); groups.push({ type: 'item', items: [entry], itemStatuses: [ChatMessageStructurePolicy.itemStatus(entry, index, activeItemIndex, @@ -163,8 +172,8 @@ export class ChatMessageStructurePolicy { itemStreaming: [ChatMessageStructurePolicy.itemShouldStream(entry, index, activeItemIndex, activeScope)], itemChildActiveScopes: [ChatMessageStructurePolicy.itemChildActiveScope(entry, index, activeItemIndex, activeScope)], - tools: [], key: ChatMessageStructurePolicy.structuredItemKey(entry, `${prefix}-${index}`), - path: `${prefix}-${index}` + tools: [], key: ChatMessageStructurePolicy.structuredItemKey(entry, itemPath), + path: itemPath }); }); flushActivity(); @@ -180,29 +189,35 @@ export class ChatMessageStructurePolicy { * owns them before grouping ordinary transcript activity. Older desktop * builds did not emit the marker, so while a Task is running we use the same * conservative fallback as mobile web and scope its following process items. + * + * A Task's children can arrive nested in its own items and again flat behind + * it, so folding the restatement would draw every child twice. A counted + * restatement is skipped, and a child that legitimately repeats survives. */ static scopeSubagentItems(items: ConversationUiMessageItem[]): ConversationUiMessageItem[] { const result: ConversationUiMessageItem[] = []; const hasSubagentMarkers = items.some((entry: ConversationUiMessageItem) => entry.is_subagent === true); let currentTask: ConversationUiMessageItem | undefined = undefined; + let carried: number[] = []; items.forEach((entry: ConversationUiMessageItem) => { if (ChatMessageStructurePolicy.isParentTaskEntry(entry)) { const task = ChatMessageStructurePolicy.copyItem(entry); result.push(task); currentTask = task; + carried = (task.subItems || []).map((_child: ConversationUiMessageItem, index: number) => index); return; } if (entry.is_subagent === true) { if (currentTask) { - currentTask.subItems = (currentTask.subItems || []).concat([entry]); + ChatMessageStructurePolicy.appendScopedChild(currentTask, carried, entry); } return; } if (!hasSubagentMarkers && currentTask && ChatMessageStructurePolicy.isRunningTaskEntry(currentTask) && ChatMessageStructurePolicy.isLegacySubagentProcessEntry(entry)) { - currentTask.subItems = (currentTask.subItems || []).concat([entry]); + ChatMessageStructurePolicy.appendScopedChild(currentTask, carried, entry); return; } result.push(entry); @@ -210,6 +225,83 @@ export class ChatMessageStructurePolicy { return result; } + /** Only original nested occurrences are matched; new flat entries consume no quota. */ + private static appendScopedChild( + task: ConversationUiMessageItem, carried: number[], entry: ConversationUiMessageItem + ): void { + const children = (task.subItems || []).slice(); + const match = carried.findIndex((index: number) => + ChatMessageStructurePolicy.sameChildIdentity(children[index], entry)); + if (match < 0) { + children.push(entry); + } else { + const index = carried.splice(match, 1)[0]; + if (entry.tool) { + // Flat records may carry newer status/output for the same nested tool. + const updated = ChatMessageStructurePolicy.copyItem(entry); + if (entry.subItems === undefined) updated.subItems = children[index].subItems; + if (entry.content === undefined) updated.content = children[index].content; + updated.tool = ChatMessageStructurePolicy.mergeChildTool(children[index].tool, entry.tool); + children[index] = updated; + } + } + task.subItems = children; + } + + /** + * What makes two subagent children the same piece of work. + * + * Children arrive without an id, so the kind, its content, and the tool it + * names are all there is to tell one from the other. + */ + private static childFingerprint(entry: ConversationUiMessageItem): string { + return [ + (entry.type || '').toLowerCase(), + entry.content || '', + entry.tool ? ChatMessageStructurePolicy.toolIdentity(entry.tool) : '' + ].join('\u0000'); + } + + private static mergeChildTool( + previous: ConversationUiToolStatus | undefined, incoming: ConversationUiToolStatus + ): ConversationUiToolStatus { + return { + id: incoming.id ?? previous?.id, + name: incoming.name ?? previous?.name, + status: incoming.status ?? previous?.status, + duration_ms: incoming.duration_ms ?? previous?.duration_ms, + start_ms: incoming.start_ms ?? previous?.start_ms, + input_preview: incoming.input_preview ?? previous?.input_preview, + tool_input: incoming.tool_input ?? previous?.tool_input, + stdout: incoming.stdout ?? previous?.stdout, + stderr: incoming.stderr ?? previous?.stderr, + tool_output: incoming.tool_output ?? previous?.tool_output, + result_preview: incoming.result_preview ?? previous?.result_preview, + error_preview: incoming.error_preview ?? previous?.error_preview, + exit_code: incoming.exit_code ?? previous?.exit_code, + plan: incoming.plan ?? previous?.plan, + permission_request_id: incoming.permission_request_id ?? previous?.permission_request_id, + }; + } + + private static sameChildIdentity(a: ConversationUiMessageItem, b: ConversationUiMessageItem): boolean { + const aId = a.tool?.id || ''; + const bId = b.tool?.id || ''; + if (aId || bId) return aId.length > 0 && aId === bId; + return ChatMessageStructurePolicy.childFingerprint(a) === ChatMessageStructurePolicy.childFingerprint(b); + } + + /** + * The tool a child names, identified by its id when the agent gave one. + * + * Status is left out: a restatement often carries a newer status for the same + * piece of work, and treating that as identity would draw the child twice. + */ + private static toolIdentity(tool: ConversationUiToolStatus): string { + if (tool.id) return `id:${tool.id}`; + return ChatMessageStructurePolicy.toolFingerprint(tool, false); + } + static structuredRenderKey( items: ConversationUiMessageItem[], prefix: string, @@ -236,13 +328,15 @@ export class ChatMessageStructurePolicy { static activityThinkingParts(group: StructuredRenderGroup, messageId: string): ActivityThinkingPart[] { const raw: ActivityThinkingPart[] = []; let toolsSeen = 0; + let thinkingSeen = 0; group.items.forEach((entry: ConversationUiMessageItem, index: number) => { if (ChatMessageStructurePolicy.isThinkingEntry(entry)) { raw.push({ beforeToolIndex: toolsSeen, text: entry.content || '', status: group.itemStatuses[index] || '', streaming: group.itemStreaming[index] || false, - streamKey: `${messageId}-${group.path}-thinking-${index}` + streamKey: `${messageId}-${group.path}-thinking-${thinkingSeen}` }); + thinkingSeen += 1; } else if (entry.tool) { toolsSeen += 1; } @@ -551,15 +645,21 @@ export class ChatMessageStructurePolicy { private static activityGroupKey(items: ConversationUiMessageItem[], path: string): string { const parts: string[] = []; - items.forEach((entry: ConversationUiMessageItem, index: number) => { + // Counting each kind separately keeps the key while siblings of other kinds + // arrive inside the group, the same way the group paths above do. + const ordinals: Map = new Map(); + items.forEach((entry: ConversationUiMessageItem) => { const thinking = ChatMessageStructurePolicy.isThinkingEntry(entry); + const kind = thinking ? 'thinking' : entry.tool ? 'tool' : 'item'; + const ordinal = ordinals.get(kind) || 0; + ordinals.set(kind, ordinal + 1); parts.push([ - `${index}`, entry.type || '', + `${kind}-${ordinal}`, entry.type || '', thinking ? 'thinking' : `${(entry.content || '').length}:${ChatMessageStructurePolicy.stableTextHash(entry.content || '')}`, entry.tool ? `${entry.tool.id || ''}:${entry.tool.name || ''}:${entry.tool.status || ''}` : '' ].join(':')); }); - return `${path}-${items.length}-${ChatMessageStructurePolicy.stableTextHash(parts.join('|'))}`; + return `${path}-${ChatMessageStructurePolicy.stableTextHash(parts.join('|'))}`; } private static stableTextHash(text: string): string { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/HistoryPageArrivalTracker.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/HistoryPageArrivalTracker.ets new file mode 100644 index 0000000000..ab81f6fdb5 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/HistoryPageArrivalTracker.ets @@ -0,0 +1,11 @@ +/** One automatic page per deliberate drag; layout and bounce cannot re-arm it. */ +export class HistoryPageArrivalTracker { + private consumed: boolean = true; + beginGesture(): void { this.consumed = false; } + arrived(atStart: boolean): boolean { + if (!atStart || this.consumed) return false; + this.consumed = true; + return true; + } + cancelArrival(): void { this.consumed = true; } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets index afc8a004d0..a98458d07d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets @@ -32,6 +32,12 @@ export class RemoteTranscriptController { private isSyncingAfterTurn: boolean = false; private isRebuildingRemoteTranscript: boolean = false; private pendingTranscriptCache: number = -1; + // True while the transcript changed without a write behind it. A history page + // is read oldest-first, so every record of the burst lands in front of the + // window and nothing already written can be reused: writing during the burst + // rewrites the whole transcript once per record, on the thread that draws the + // screen, for a page nobody has finished reading yet. + private transcriptDirty: boolean = false; constructor( remote: RemotePageState, @@ -51,6 +57,8 @@ export class RemoteTranscriptController { this.knownPollVersionValue = 0; this.knownModelCatalogVersion = 0; this.knownRemoteMessageCount = 0; + this.cancelPendingTranscriptCache(); + this.transcriptDirty = false; } updateKnownMessageCount(pollVersion: number, knownMessageCount: number): void { @@ -316,16 +324,26 @@ export class RemoteTranscriptController { return; } if (snapshot.historyRewritten) { + this.cancelPendingTranscriptCache(); + this.transcriptDirty = false; this.rebuildRemoteTranscript(snapshot.sessionId); return; } runtime.timeline.applySnapshot(snapshot); this.syncRemoteTimeline(); - if (snapshot.messageSnapshot !== undefined) { + if (snapshot.historyLoading === true) { + // A history page publishes once per record it contains, and every one of + // them lands in front of the window, so no row already on disk survives: + // writing during the burst rewrites the whole transcript on the thread + // that draws the screen, for a cache nothing reads before the next open. + this.deferTranscriptCache(); + } else if (this.transcriptDirty) { + // The page settled, and the settle publishes again. + this.cacheTranscriptNow(snapshot.sessionId, true); + } else if (snapshot.messageSnapshot !== undefined) { // A streaming turn restates the whole transcript on every chunk. Writing // all of it back each time re-encrypts the session dozens of times a - // second for a cache nothing reads until the next open, so during a turn - // only the last write of a burst is kept. + // second, so during a turn only the last write of a burst is kept. if (this.hasRunningRemoteTurn()) this.scheduleTranscriptCache(snapshot.sessionId); else this.cacheTranscriptNow(snapshot.sessionId, true); } else if (snapshot.newMessages.length > 0) { @@ -362,19 +380,37 @@ export class RemoteTranscriptController { * anyway. */ private scheduleTranscriptCache(sessionId: string): void { + this.transcriptDirty = true; if (this.pendingTranscriptCache !== -1) return; this.pendingTranscriptCache = setTimeout(() => { this.pendingTranscriptCache = -1; this.cacheRemoteTranscript(sessionId, true); + this.transcriptDirty = false; }, REMOTE_TRANSCRIPT_CACHE_INTERVAL_MS); } - private cacheTranscriptNow(sessionId: string, authoritativeSnapshot: boolean): void { + /** + * Holds the transcript write back for the rest of a history page. + * + * Nothing reads the cache before the next open, and the page publishes again + * when its read settles, which is where the deferred write lands. + */ + private deferTranscriptCache(): void { + this.transcriptDirty = true; + this.cancelPendingTranscriptCache(); + } + + private cancelPendingTranscriptCache(): void { if (this.pendingTranscriptCache !== -1) { clearTimeout(this.pendingTranscriptCache); this.pendingTranscriptCache = -1; } + } + + private cacheTranscriptNow(sessionId: string, authoritativeSnapshot: boolean): void { + this.cancelPendingTranscriptCache(); this.cacheRemoteTranscript(sessionId, authoritativeSnapshot); + this.transcriptDirty = false; } private cacheRemoteTranscript(sessionId: string, authoritativeSnapshot: boolean): void { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatSessionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatSessionController.ets index 99d1a7c570..abc40a11b3 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatSessionController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatSessionController.ets @@ -67,6 +67,7 @@ export class ChatSessionController { private completedTurnId: string = ''; private hasMoreMessages: boolean = false; private historyLoading: boolean = false; + private historyReplaying: boolean = false; private historyFailed: boolean = false; private modelReadVersion: number = 0; private pendingModelCatalog?: RemoteModelCatalog; @@ -83,7 +84,7 @@ export class ChatSessionController { this.stop(); this.sessionId = sessionId; this.cursor = cursor; this.reducer = new DurableSessionReducer(); this.mailbox.select(sessionId); this.stopped = false; this.caughtUp = false; this.sessionState = 'idle'; this.completedTurnId = ''; this.hasMoreMessages = false; - this.historyLoading = false; this.historyFailed = false; + this.historyLoading = false; this.historyReplaying = false; this.historyFailed = false; this.pendingModelCatalog = undefined; const epoch = this.generation; void this.loadInitialModelCatalog(sessionId, epoch); @@ -95,20 +96,24 @@ export class ChatSessionController { // must not delay the serial transcript stream or its durable cursor. if (event.event === 'session-interaction-changed') { void this.mailbox.refresh(); return; } if (!this.reducer.apply(event)) return; - if (this.caughtUp) this.publish(); + if (this.caughtUp && !this.historyReplaying) this.publish(); }, onResumed: async (): Promise => { if (this.current(epoch)) void this.mailbox.refresh(); }, onGap: async (): Promise => { // The desktop restarted the stream: everything reduced so far came // from its previous epoch and is replayed from the latest page. if (!this.current(epoch)) return; + this.historyReplaying = true; this.reducer = new DurableSessionReducer(); this.hasMoreMessages = false; }, onCaughtUp: async (): Promise => { if (!this.current(epoch)) return; - this.caughtUp = true; this.publish(); + this.caughtUp = true; this.historyReplaying = false; this.publish(); }, onHistory: (hasMore: boolean): void => { this.hasMoreMessages = hasMore; }, + onHistoryReplay: (active: boolean): void => { + if (this.current(epoch)) this.historyReplaying = active; + }, onHistoryState: (loading: boolean, failed: boolean): void => { if (!this.current(epoch)) return; this.historyLoading = loading; this.historyFailed = failed; @@ -127,7 +132,7 @@ export class ChatSessionController { const catalog = await this.sessionManager.getModelCatalog(sessionId); if (!this.current(epoch) || version !== this.modelReadVersion) return; this.pendingModelCatalog = catalog; - if (this.caughtUp) this.publish(); + if (this.caughtUp && !this.historyReplaying) this.publish(); } catch (error) { if (this.current(epoch) && version === this.modelReadVersion) this.callbacks.onError(error); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/DurableSessionReducer.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/DurableSessionReducer.ets index bed0bbee59..c32811d4ae 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/DurableSessionReducer.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/DurableSessionReducer.ets @@ -18,6 +18,7 @@ export interface SessionRecord { sessionId: string; id: string; revision: number interface StoredTurn { revision: number; data?: TurnRecord; authoritative?: boolean; } interface StoredRound { revision: number; data?: RoundRecord; } interface StoredItem { revision: number; turnId: string; roundId: string; data: ItemRecord; } +interface RenderedTurn { generation: number; messages: ChatMessage[]; } /** Lossless record storage follows Happy's stable-id message reduction. Mobile * presentation adapts the existing persisted turn schema; it does not define a @@ -28,6 +29,22 @@ export class DurableSessionReducer { private turns: Map = new Map(); private rounds: Map = new Map(); private items: Map = new Map(); + // One turn's messages are rebuilt only when something that can change them + // arrived. A record carries no usable "this turn's content changed" flag (a + // round or item record repeats its parent turn header without advancing the + // turn's own revision), so the reducer counts the arrivals itself. + private turnGenerations: Map = new Map(); + private renderedTurns: Map = new Map(); + private generationSeq: number = 0; + private touchTurn(turnId: string): void { + this.generationSeq++; + this.turnGenerations.set(turnId, this.generationSeq); + } + /** A tombstone names its turn only when the host repeated the header. */ + private forgetTurnOrAll(record: SessionRecord): void { + const turnId = record.turn?.turnId; + if (turnId) this.touchTurn(turnId); else this.renderedTurns.clear(); + } apply(event: HostSessionEvent): boolean { if (event.event !== 'session-record') return false; const record = event.payload as SessionRecord; @@ -41,11 +58,15 @@ export class DurableSessionReducer { if (record.id.startsWith('turn/')) { const id = record.id.slice(5); const previousTurn = this.turns.get(id); if (!previousTurn || previousTurn.revision < record.revision) this.turns.set(id, { revision: record.revision }); + this.touchTurn(id); } else if (record.id.startsWith('round/')) { const id = record.id.slice(6); const previousRound = this.rounds.get(id); if (!previousRound || previousRound.revision < record.revision) this.rounds.set(id, { revision: record.revision }); - } else if (record.id.startsWith('item/')) this.items.delete(record.id.slice(5)); - else throw new Error('Unknown session tombstone kind'); + this.forgetTurnOrAll(record); + } else if (record.id.startsWith('item/')) { + this.items.delete(record.id.slice(5)); + this.forgetTurnOrAll(record); + } else throw new Error('Unknown session tombstone kind'); return true; } const data = record.turn; @@ -72,6 +93,7 @@ export class DurableSessionReducer { if (!item || item.revision < record.revision) this.items.set(record.item.data.id, { revision: record.revision, turnId: data.turnId, roundId: record.round.id, data: record.item }); } + this.touchTurn(data.turnId); return true; } messages(): ChatMessage[] { @@ -90,44 +112,62 @@ export class DurableSessionReducer { const turn = stored.data; if (!turn) continue; if (turn.kind === 'local_command') continue; - const metadata = turn.userMessage.metadata as UserMetadata | undefined; - const userText = metadata?.original_text || turn.userMessage.content; - if (userText.length > 0 || (metadata?.images?.length || 0) > 0) result.push({ id: turn.userMessage.id, turnId: turn.turnId, - role: 'user', text: userText, images: metadata?.images, status: 'sent', timestamp: new Date(turn.userMessage.timestamp).toISOString() }); - const turnFence = this.tombstones.get(`turn/${turn.turnId}`) ?? 0; - const entries = (byTurn.get(turn.turnId) ?? []).filter((item: StoredItem) => { - const round = this.rounds.get(item.roundId); - const roundFence = Math.max(turnFence, this.tombstones.get(`round/${item.roundId}`) ?? 0); - return !!round?.data && round.revision > roundFence && - item.revision > Math.max(roundFence, this.tombstones.get(`item/${item.data.data.id}`) ?? 0); - }) - .sort((a: StoredItem, b: StoredItem) => { - const roundA = this.rounds.get(a.roundId)?.data?.roundIndex || 0; - const roundB = this.rounds.get(b.roundId)?.data?.roundIndex || 0; - return roundA - roundB || (a.data.data.orderIndex ?? a.data.data.timestamp ?? 0) - - (b.data.data.orderIndex ?? b.data.data.timestamp ?? 0) || a.data.data.id.localeCompare(b.data.data.id); - }); - const items: ChatMessageItemResponse[] = []; - const tools: RemoteToolStatusResponse[] = []; - for (const entry of entries) { - const data = entry.data.data; - if (data.status === 'superseded' || data.status === 'retry_superseded') continue; - const round = this.rounds.get(entry.roundId)?.data?.roundIndex || 0; - const item: ChatMessageItemResponse = { type: entry.data.type, content: data.content, round_index: round, is_subagent: data.isSubagentItem === true || !!data.subagentSessionId }; - if (entry.data.type === 'tool') { - const tool: RemoteToolStatusResponse = { id: data.toolCall?.id || data.id, name: data.toolName, status: data.status || (data.toolResult ? (data.toolResult.success ? 'completed' : 'failed') : 'running'), - tool_input: data.toolCall?.input, tool_output: data.toolResult?.result, - duration_ms: data.durationMs ?? data.toolResult?.durationMs, error_preview: data.toolResult?.error, start_ms: data.startTime }; - item.tool = tool; tools.push(tool); - } - items.push(item); + const generation = this.turnGenerations.get(turn.turnId) ?? 0; + const cached = this.renderedTurns.get(turn.turnId); + if (cached && cached.generation === generation) { + for (const message of cached.messages) result.push(message); + continue; + } + const rendered = this.renderTurn(turn, stored, byTurn.get(turn.turnId) ?? []); + this.renderedTurns.set(turn.turnId, { generation: generation, messages: rendered }); + for (const message of rendered) result.push(message); + } + // Dropped turns must not pin their messages, and the cache must not outlive + // the turns it describes. + const live: Set = new Set(); + for (const stored of turns) { const turn = stored.data; if (turn) live.add(turn.turnId); } + for (const key of Array.from(this.renderedTurns.keys())) if (!live.has(key)) this.renderedTurns.delete(key); + return result; + } + private renderTurn(turn: TurnRecord, stored: StoredTurn, entries: StoredItem[]): ChatMessage[] { + const result: ChatMessage[] = []; + const metadata = turn.userMessage.metadata as UserMetadata | undefined; + const userText = metadata?.original_text || turn.userMessage.content; + if (userText.length > 0 || (metadata?.images?.length || 0) > 0) result.push({ id: turn.userMessage.id, turnId: turn.turnId, + role: 'user', text: userText, images: metadata?.images, status: 'sent', timestamp: new Date(turn.userMessage.timestamp).toISOString() }); + const turnFence = this.tombstones.get(`turn/${turn.turnId}`) ?? 0; + const visible = entries.filter((item: StoredItem) => { + const round = this.rounds.get(item.roundId); + const roundFence = Math.max(turnFence, this.tombstones.get(`round/${item.roundId}`) ?? 0); + return !!round?.data && round.revision > roundFence && + item.revision > Math.max(roundFence, this.tombstones.get(`item/${item.data.data.id}`) ?? 0); + }) + .sort((a: StoredItem, b: StoredItem) => { + const roundA = this.rounds.get(a.roundId)?.data?.roundIndex || 0; + const roundB = this.rounds.get(b.roundId)?.data?.roundIndex || 0; + return roundA - roundB || (a.data.data.orderIndex ?? a.data.data.timestamp ?? 0) - + (b.data.data.orderIndex ?? b.data.data.timestamp ?? 0) || a.data.data.id.localeCompare(b.data.data.id); + }); + const items: ChatMessageItemResponse[] = []; + const tools: RemoteToolStatusResponse[] = []; + for (const entry of visible) { + const data = entry.data.data; + if (data.status === 'superseded' || data.status === 'retry_superseded') continue; + const round = this.rounds.get(entry.roundId)?.data?.roundIndex || 0; + const item: ChatMessageItemResponse = { type: entry.data.type, content: data.content, round_index: round, is_subagent: data.isSubagentItem === true || !!data.subagentSessionId }; + if (entry.data.type === 'tool') { + const tool: RemoteToolStatusResponse = { id: data.toolCall?.id || data.id, name: data.toolName, status: data.status || (data.toolResult ? (data.toolResult.success ? 'completed' : 'failed') : 'running'), + tool_input: data.toolCall?.input, tool_output: data.toolResult?.result, + duration_ms: data.durationMs ?? data.toolResult?.durationMs, error_preview: data.toolResult?.error, start_ms: data.startTime }; + item.tool = tool; tools.push(tool); } - const status = turn.status === 'inprogress' ? 'active' : turn.status === 'error' ? 'failed' : turn.status; - result.push({ id: `assistant-${turn.turnId}`, turnId: turn.turnId, role: 'assistant', status: status, - text: items.filter((item: ChatMessageItemResponse) => item.type === 'text' && !item.is_subagent).map((item: ChatMessageItemResponse) => item.content || '').join(''), - thinking: items.filter((item: ChatMessageItemResponse) => item.type === 'thinking' && !item.is_subagent).map((item: ChatMessageItemResponse) => item.content || '').join(''), - tools: tools, items: items, timestamp: new Date(turn.timestamp).toISOString(), error: turn.error, renderVersion: stored.revision }); + items.push(item); } + const status = turn.status === 'inprogress' ? 'active' : turn.status === 'error' ? 'failed' : turn.status; + result.push({ id: `assistant-${turn.turnId}`, turnId: turn.turnId, role: 'assistant', status: status, + text: items.filter((item: ChatMessageItemResponse) => item.type === 'text' && !item.is_subagent).map((item: ChatMessageItemResponse) => item.content || '').join(''), + thinking: items.filter((item: ChatMessageItemResponse) => item.type === 'thinking' && !item.is_subagent).map((item: ChatMessageItemResponse) => item.content || '').join(''), + tools: tools, items: items, timestamp: new Date(turn.timestamp).toISOString(), error: turn.error, renderVersion: stored.revision }); return result; } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/HostSessionStream.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/HostSessionStream.ets index 9453ace299..b3324531ca 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/HostSessionStream.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/HostSessionStream.ets @@ -7,6 +7,21 @@ export const REMOTE_CAPABILITY_HOST_STREAM_V1: string = 'host_stream_v1'; /** Hosts keep hint subscriptions alive for 10 minutes; renew well before. */ const HOST_STREAM_KEEPALIVE_MS: number = 4 * 60 * 1000; +/** + * How many pages one history request may read while it has not yet shown the + * reader anything it did not already have. + * + * Host pages are cut by sequence, and a record's sequence is the moment it was + * last updated, so one long turn owns every record it produced: a page of + * history can be nothing but more records of the turn that is already on + * screen. Reading exactly one page per request then looks like "history loaded" + * while the transcript above stayed the same. The budget keeps that walk bounded + * (a request is a user gesture, not an unbounded download); the next request + * continues from where this one stopped. Mirrors `MAX_HISTORY_PAGES_PER_REQUEST` + * in the shared core transport. + */ +export const MAX_HISTORY_PAGES_PER_REQUEST: number = 4; + /** One record as consumed by reducers; `session_id` is the stream id. */ export interface HostSessionEvent { session_id: string; event: string; payload: Object; } @@ -40,6 +55,8 @@ export interface HostSessionCallbacks { onResumed?: () => Promise; onError: (error: Error) => void; onHistory?: (hasMore: boolean) => void; + /** Local reducer boundary; separate from time spent awaiting the history RPC. */ + onHistoryReplay?: (active: boolean) => void; onHistoryState?: (loading: boolean, failed: boolean) => void; /** The host restarted the stream; drop derived state before the latest page is replayed. */ onGap?: (reason: string) => Promise; @@ -68,6 +85,21 @@ export function parseHostStreamHint(sourceDeviceId: string, plaintext: HostStrea return { sourceDeviceId: sourceDeviceId, streamId: payload.stream_id, epoch: payload.epoch, cursor: payload.cursor }; } +/** The part of a host record this reader needs: the turn it belongs to. */ +interface HostStreamTurnHeader { turnId?: string; } +interface HostStreamRecordTurn { turn?: HostStreamTurnHeader; } + +/** + * The turn a stream event belongs to, for the reader's "is this older than what + * the caller already has" check. `session-record` payloads always carry + * `turn.turnId`; control events belong to no turn and are ignored. + */ +export function hostStreamEventTurnId(event: HostStreamEventWire): string | undefined { + if (event.event !== 'session-record') return undefined; + const turnId = (event.payload as HostStreamRecordTurn).turn?.turnId; + return typeof turnId === 'string' ? turnId : undefined; +} + /** Rejects a page the host did not accept and asserts it answers this stream. */ export function checkHostStreamPage(streamId: string, page: HostStreamPage): HostStreamPage { if (page.resp === 'error') { @@ -106,6 +138,8 @@ export class HostSessionStream { private oldest: number = 1; private hasOlder: boolean = false; private truncated: boolean = false; + /** Turns already emitted to the caller, i.e. already part of the transcript. */ + private emittedTurns: Set = new Set(); private historyRequested: boolean = false; private retryTimer: number = -1; private keepaliveTimer: number = -1; @@ -178,6 +212,8 @@ export class HostSessionStream { for (const event of page.events) { if (this.stopped) return; await this.callbacks.onEvent({ session_id: this.streamId, event: event.event, payload: event.payload }); + const turnId = hostStreamEventTurnId(event); + if (turnId !== undefined) this.emittedTurns.add(turnId); } } @@ -218,19 +254,35 @@ export class HostSessionStream { } private async older(): Promise { - if (!this.hasOlder) return; - const page = await this.read({ before: this.oldest, epoch: this.epoch }); - if (this.stopped) return; - if (page.epoch !== this.epoch) { - await this.gap(); - await this.resync(); - throw new Error('Session history restarted on the host; reloaded from its latest page'); + let pages = 0; + try { + while (this.hasOlder && pages < MAX_HISTORY_PAGES_PER_REQUEST && !this.stopped) { + const page = await this.read({ before: this.oldest, epoch: this.epoch }); + if (this.stopped) return; + if (page.epoch !== this.epoch) { + await this.gap(); + await this.resync(); + throw new Error('Session history restarted on the host; reloaded from its latest page'); + } + // A page that only repeats turns the transcript already has is not + // progress: keep reading until the caller gets an older turn, the host + // runs out of history, or this request's budget is spent. + const showsAnOlderTurn = page.events.some((event) => { + const turnId = hostStreamEventTurnId(event); + return turnId !== undefined && !this.emittedTurns.has(turnId); + }); + if (pages === 0) this.callbacks.onHistoryReplay?.(true); + await this.emitPage(page); + if (page.events.length > 0) this.oldest = page.events[0].seq; + this.hasOlder = page.has_more; + this.truncated = page.truncated === true; + pages++; + if (showsAnOlderTurn) break; + } + } finally { + if (pages > 0) this.callbacks.onHistory?.(this.hasOlder); + this.callbacks.onHistoryReplay?.(false); } - await this.emitPage(page); - if (page.events.length > 0) this.oldest = page.events[0].seq; - this.hasOlder = page.has_more; - this.truncated = page.truncated === true; - this.callbacks.onHistory?.(this.hasOlder); } private async catchUp(): Promise { diff --git a/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets index 90c0c7cdc8..92ed8255f4 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets @@ -25,7 +25,7 @@ import { import { AssistantProcessLayoutPolicy } from '../main/ets/pages/policy/AssistantProcessLayoutPolicy'; import { ConversationMessageRenderPolicy } from '../main/ets/pages/policy/ConversationMessageRenderPolicy'; import { ToolCollapsePolicy } from '../main/ets/pages/policy/ToolCollapsePolicy'; -import { ChatMessageStructurePolicy } from '../main/ets/pages/policy/ChatMessageStructurePolicy'; +import { ChatMessageStructurePolicy, StructuredRenderGroup } from '../main/ets/pages/policy/ChatMessageStructurePolicy'; import { ToolStatusPresentationPolicy } from '../main/ets/pages/policy/ToolStatusPresentationPolicy'; import { PlanToolPolicy } from '../main/ets/pages/policy/PlanToolPolicy'; import { @@ -335,6 +335,119 @@ export default function conversationPresentationUnitTest() { expect(parentOutput[0].subItems?.length || 0).assertEqual(0); }); + it('folds a Task restatement once instead of drawing its children twice', 0, () => { + const task = toolStatus('task-1', 'Task', 'running'); + const childThinking: ConversationUiMessageItem = { + type: 'thinking', + content: 'Inspect child files', + is_subagent: true + }; + const childRead: ConversationUiMessageItem = { + type: 'tool', + tool: toolStatus('read-child', 'Read', 'running'), + is_subagent: true + }; + const parentTask: ConversationUiMessageItem = { type: 'tool', tool: task }; + parentTask.subItems = [childThinking, childRead]; + + const scoped = ChatMessageStructurePolicy.scopeSubagentItems([parentTask, childThinking, childRead]); + expect(scoped.length).assertEqual(1); + expect(scoped[0].subItems?.length || 0).assertEqual(2); + }); + + it('keeps a child that repeats inside the Task', 0, () => { + const task = toolStatus('task-2', 'Task', 'running'); + const childRead: ConversationUiMessageItem = { + type: 'tool', + tool: toolStatus('read-child-2', 'Read', 'completed'), + is_subagent: true + }; + const parentTask: ConversationUiMessageItem = { type: 'tool', tool: task }; + parentTask.subItems = [childRead, childRead]; + + const scoped = ChatMessageStructurePolicy.scopeSubagentItems([parentTask, childRead, childRead]); + expect(scoped[0].subItems?.length || 0).assertEqual(2); + }); + + it('matches only original nested occurrences and preserves repeated flat text', 0, () => { + [true, false].forEach((marked: boolean) => { + [0, 1, 2].forEach((nestedCount: number) => { + const child: ConversationUiMessageItem = { type: 'text', content: 'Checking again', is_subagent: marked }; + const nested: ConversationUiMessageItem[] = []; + for (let index = 0; index < nestedCount; index++) nested.push(child); + const task: ConversationUiMessageItem = { + type: 'tool', tool: toolStatus('task', 'Task', 'running'), subItems: nested + }; + const scoped = ChatMessageStructurePolicy.scopeSubagentItems([task, child, child, child, child, child]); + expect(scoped[0].subItems?.length).assertEqual(5); + expect(task.subItems?.length).assertEqual(nestedCount); + }); + }); + }); + + it('updates a nested tool from its flat restatement without mutating the source', 0, () => { + [true, false].forEach((marked: boolean) => { + const nested: ConversationUiMessageItem = { + type: 'tool', content: 'old', tool: toolStatus('read', 'Read', 'running'), + subItems: [{ type: 'text', content: 'nested detail' }] + }; + const task: ConversationUiMessageItem = { + type: 'tool', tool: toolStatus('task', 'Task', 'running'), subItems: [nested] + }; + const completed = toolStatus('read', 'Read', 'completed'); + completed.result_preview = 'new output'; + completed.name = undefined; + if (nested.tool) nested.tool.input_preview = 'original input'; + const flat: ConversationUiMessageItem = { type: 'tool', content: 'new', tool: completed, is_subagent: marked }; + const children = ChatMessageStructurePolicy.scopeSubagentItems([task, flat])[0].subItems || []; + expect(children.length).assertEqual(1); + expect(children[0].tool?.status).assertEqual('completed'); + expect(children[0].tool?.result_preview).assertEqual('new output'); + expect(children[0].content).assertEqual('new'); + expect(children[0].tool?.name).assertEqual('Read'); + expect(children[0].tool?.input_preview).assertEqual('original input'); + expect(children[0].subItems?.length).assertEqual(1); + expect(nested.tool?.status).assertEqual('running'); + }); + }); + + it('keeps a reasoning block path when an earlier item arrives in front of it', 0, () => { + const onlyThinking = ChatMessageStructurePolicy.structuredGroups([ + { type: 'thinking', content: 'Inspect' } + ], 'item', true, 'active'); + const withSteeringFirst = ChatMessageStructurePolicy.structuredGroups([ + { type: 'user-steering', steering_id: 'steering-1', content: 'Use plan B' }, + { type: 'thinking', content: 'Inspect' } + ], 'item', true, 'active'); + expect(onlyThinking.length).assertEqual(1); + expect(onlyThinking[0].type).assertEqual('item'); + const thinkingGroup = withSteeringFirst.filter((group: StructuredRenderGroup) => + group.items.length > 0 && ChatMessageStructurePolicy.isThinkingEntry(group.items[0]))[0]; + expect(thinkingGroup.path).assertEqual(onlyThinking[0].path); + expect(thinkingGroup.key).assertEqual(onlyThinking[0].key); + }); + + it('keeps a Task path and its children when an earlier tool arrives late', 0, () => { + const task = toolStatus('task-3', 'Task', 'running'); + const childThinking: ConversationUiMessageItem = { + type: 'thinking', + content: 'Child reasoning', + is_subagent: true + }; + const parentTask: ConversationUiMessageItem = { type: 'tool', tool: task }; + parentTask.subItems = [childThinking]; + + const taskOnly = ChatMessageStructurePolicy.structuredGroups([parentTask], 'item', true, 'active'); + const withEarlierTool = ChatMessageStructurePolicy.structuredGroups([ + { type: 'tool', tool: toolStatus('read-late', 'Read', 'completed') }, + parentTask + ], 'item', true, 'active'); + const taskGroup = withEarlierTool.filter((group: StructuredRenderGroup) => + group.type === 'item' && (group.items[0].tool?.name || '') === 'Task')[0]; + expect(taskOnly[0].path).assertEqual(taskGroup.path); + expect((taskGroup.items[0].subItems || []).length).assertEqual(1); + }); + it('keeps one plain assistant text item on the lightweight markdown path', 0, () => { expect(ChatMessageStructurePolicy.requiresStructuredRendering([ { type: 'text', content: 'Hello' } diff --git a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets index e82eed79cd..1e174e2cec 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets @@ -123,7 +123,7 @@ import { } from '../main/ets/model/RemoteModels'; import { - durableRecord, + hostRecord, ToolInputFixture, CryptoImageFixture, CryptoCommandFixture, @@ -1760,7 +1760,7 @@ export default function conversationStateUnitTest() { // The round and item records repeat the turn only as a parent header, and // that copy carries the attachment as it was recorded rather than the // pixels the turn record was given. It must not replace the turn. - reducer.apply(durableRecord('session', 5, 'on it')); + reducer.apply(hostRecord('session', 5, 'on it')); const reduced = reducer.messages(); expect(reduced[0].text).assertEqual('look at this'); expect((reduced[0].images || []).length).assertEqual(1); diff --git a/src/apps/mobile/harmonyos/tools/tests/history-page-arrival.test.cjs b/src/apps/mobile/harmonyos/tools/tests/history-page-arrival.test.cjs new file mode 100644 index 0000000000..1d11938c82 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/history-page-arrival.test.cjs @@ -0,0 +1,29 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); +function load(name) { + const source = fs.readFileSync(path.join(__dirname, '../../entry/src/main/ets/pages/policy', `${name}.ets`), 'utf8'); + const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText; + const exported = {}; + new Function('require', 'exports', compiled)(() => ({}), exported); + return exported; +} +const { HistoryPageArrivalTracker } = load('HistoryPageArrivalTracker'); + +test('layout cannot request a page and bounce cannot rearm the same gesture', () => { + const tracker = new HistoryPageArrivalTracker(); + assert.equal(tracker.arrived(true), false); + tracker.beginGesture(); + assert.equal(tracker.arrived(false), false); + assert.equal(tracker.arrived(true), true); + for (let i = 0; i < 10; i++) { + assert.equal(tracker.arrived(false), false); + assert.equal(tracker.arrived(true), false); + } + tracker.beginGesture(); + assert.equal(tracker.arrived(true), true); + tracker.cancelArrival(); + assert.equal(tracker.arrived(true), false); +}); diff --git a/src/apps/mobile/harmonyos/tools/tests/host-stream.test.cjs b/src/apps/mobile/harmonyos/tools/tests/host-stream.test.cjs index 0291b09841..8d7efa613c 100644 --- a/src/apps/mobile/harmonyos/tools/tests/host-stream.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/host-stream.test.cjs @@ -18,7 +18,7 @@ const tick = (ms = 5) => new Promise(resolve => setTimeout(resolve, ms)); /** An in-memory desktop: one stream log with an epoch, paged like `HostStreamHub`. */ function fakeHost(streamId, pageSize = 2) { const host = { - epoch: 1, events: [], reads: [], unsubscribed: 0, hints: new Set(), reconnects: new Set(), failNext: null, + epoch: 1, events: [], reads: [], unsubscribed: 0, hints: new Set(), reconnects: new Set(), failNext: null, gate: null, append(event, payload = {}) { host.events.push({ seq: host.events.length + 1, event, payload }); return host.events[host.events.length - 1].seq; }, restart() { host.epoch++; host.events = []; }, hint() { for (const fn of host.hints) fn({ sourceDeviceId: 'desktop', streamId, epoch: host.epoch, cursor: host.events.length }); }, @@ -43,6 +43,8 @@ function fakeHost(streamId, pageSize = 2) { target: 'desktop', read: async request => { host.reads.push({ ...request }); + // A forward catch-up can be parked so a test can queue hints behind it. + if (request.after !== undefined && host.gate) await host.gate; if (host.failNext) { const failure = host.failNext; host.failNext = null; throw failure; } return host.page(request); }, @@ -96,7 +98,9 @@ test('opens on the latest page, applies hints forward and ignores stale or forei test('loadOlder walks history backwards while the forward cursor stays monotonic', async () => { const host = fakeHost('session'); - for (const id of ['1', '2', '3', '4', '5']) host.append('session-record', { id }); + // One turn per record: every older page shows a turn the transcript does not + // have yet, so each request reads exactly the page it was asked for. + for (const id of ['1', '2', '3', '4', '5']) host.append('session-record', { id, turn: { turnId: `t${id}` } }); const c = callbacks(); const stream = new HostSessionStream('session', host.source, c.hooks); try { @@ -121,6 +125,81 @@ test('loadOlder walks history backwards while the forward cursor stays monotonic } finally { stream.close(); } }); +test('one history request reads past pages of the turn already on screen', async () => { + const host = fakeHost('session'); + for (let i = 1; i <= 2; i++) host.append('session-record', { id: `a${i}`, turn: { turnId: 't1' } }); + for (let i = 1; i <= 6; i++) host.append('session-record', { id: `b${i}`, turn: { turnId: 't2' } }); + const c = callbacks(); + const stream = new HostSessionStream('session', host.source, c.hooks); + try { + await settle(() => c.caught === 1); + assert.deepEqual(c.applied.map(e => e.payload.id), ['b5', 'b6']); + stream.loadOlder(); + await settle(() => c.applied.length === 8); + // The newest pages are more of t2, the turn already on screen: one request + // reads through them instead of reporting a load that shows nothing new. + assert.deepEqual(host.reads.slice(1).map(read => read.before), [7, 5, 3]); + assert.deepEqual(c.applied.map(e => e.payload.turn.turnId), + ['t2', 't2', 't2', 't2', 't2', 't2', 't1', 't1']); + assert.equal(c.history.at(-1), false); + } finally { stream.close(); } +}); + +test('a history request stops at its page budget and the next one continues', async () => { + const host = fakeHost('session'); + for (let i = 1; i <= 2; i++) host.append('session-record', { id: `a${i}`, turn: { turnId: 't1' } }); + for (let i = 1; i <= 16; i++) host.append('session-record', { id: `b${i}`, turn: { turnId: 't2' } }); + const c = callbacks(); + const stream = new HostSessionStream('session', host.source, c.hooks); + try { + await settle(() => c.caught === 1); + stream.loadOlder(); + await settle(() => c.applied.length === 10); + assert.deepEqual(host.reads.slice(1).map(read => read.before), [17, 15, 13, 11]); + assert.equal(c.applied.some(e => e.payload.turn.turnId === 't1'), false, 'the budget stops before t1 is reached'); + assert.equal(c.history.at(-1), true); + stream.loadOlder(); + await settle(() => c.applied.some(e => e.payload.turn.turnId === 't1')); + assert.deepEqual(host.reads.slice(5).map(read => read.before), [9, 7, 5, 3]); + assert.equal(c.history.at(-1), false); + } finally { stream.close(); } +}); + +test('a hint burst costs one catch-up and does not delay a queued history request', async () => { + const host = fakeHost('session'); + for (const id of ['1', '2', '3', '4', '5']) host.append('session-record', { id, turn: { turnId: `t${id}` } }); + let openGate; + host.gate = new Promise(resolve => { openGate = resolve; }); + const c = callbacks(); + const stream = new HostSessionStream('session', host.source, c.hooks); + try { + await settle(() => c.caught === 1); + // A streaming host fans out one hint per event. Park the catch-up the first + // hint starts, so the rest pile up behind it exactly as they do while a turn + // is streaming, and queue a history request behind all of them. + host.append('session-record', { id: '6', turn: { turnId: 't6' } }); host.hint(); + await tick(); + for (const id of ['7', '8', '9', '10']) { host.append('session-record', { id, turn: { turnId: `t${id}` } }); host.hint(); } + await tick(); + assert.equal(host.reads.filter(read => read.after !== undefined).length, 1, 'the hints queue behind one catch-up read'); + + stream.loadOlder(); + openGate(); + await settle(() => c.historyState.some(([loading]) => loading === false)); + await tick(); await tick(); + + const history = host.reads.findIndex(read => read.before !== undefined); + assert.ok(history > 0, 'the request reached the host'); + // One catch-up over five new events is three pages at this page size, so the + // history request waits for exactly those reads: the four hints that arrived + // while it was parked merged into it and into a single later refresh. + assert.deepEqual(host.reads.slice(0, history).filter(read => read.after !== undefined).map(read => read.after), [5, 7, 9]); + assert.equal(host.reads[history].before, 4, 'the history request runs right after that catch-up'); + assert.deepEqual(host.reads.slice(history + 1).filter(read => read.after !== undefined).map(read => read.after), [10], + 'the burst left one merged refresh, not one per hint'); + } finally { stream.close(); } +}); + test('a host restart is announced as a gap and replayed from the latest page', async () => { const host = fakeHost('session'); host.append('session-record', { id: 'old' }); diff --git a/src/apps/mobile/harmonyos/tools/tests/session-record.test.cjs b/src/apps/mobile/harmonyos/tools/tests/session-record.test.cjs index ac072f7014..e80cb9f79a 100644 --- a/src/apps/mobile/harmonyos/tools/tests/session-record.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/session-record.test.cjs @@ -170,3 +170,99 @@ test('tool actions use call identity and preserve zero duration', () => { assert.equal(tool.id,'call-id'); assert.equal(tool.status,'completed'); assert.equal(tool.start_ms,123); assert.equal(tool.duration_ms,0); }); + +function turnEvent(turnId, turnIndex, revision, text, status = 'completed') { + return { session_id: 'session', event: 'session-record', payload: { + sessionId: 'session', id: `item/${turnId}-text`, revision, + turn: { turnId, turnIndex, sessionId: 'session', timestamp: 1, + userMessage: { id: `${turnId}-user`, content: 'question', timestamp: 1 }, status }, + round: { id: `${turnId}-round`, turnId, roundIndex: 0, timestamp: 2, status: 'completed' }, + item: { type: 'text', data: { id: `${turnId}-text`, content: text, orderIndex: 0, timestamp: 3 } } + } }; +} + +test('an untouched turn keeps its rendered messages and a changed turn does not', () => { + const reducer = new DurableSessionReducer(); + reducer.apply(turnEvent('turn-a', 0, 1, 'first')); + const first = reducer.messages(); + assert.equal(first.length, 2); + reducer.apply(turnEvent('turn-b', 1, 1, 'second')); + const second = reducer.messages(); + assert.equal(second.length, 4); + assert.equal(second[0], first[0], 'an untouched turn keeps its user message instance'); + assert.equal(second[1], first[1], 'an untouched turn keeps its answer instance'); + assert.notEqual(second[1], second[3]); + reducer.apply(turnEvent('turn-a', 0, 2, 'first done')); + const third = reducer.messages(); + assert.equal(third[1].text, 'first done'); + assert.notEqual(third[1], second[1], 'a changed turn is rendered again'); + assert.equal(third[3], second[3], 'its neighbour is still reused'); +}); + +test('an item record repeating a parent header still refreshes its own turn', () => { + const reducer = new DurableSessionReducer(); + reducer.apply(turnEvent('turn-a', 0, 5, 'first')); + reducer.apply(turnEvent('turn-b', 1, 1, 'other')); + const before = reducer.messages(); + assert.equal(before[1].items.length, 1); + // A round or item record repeats its parent turn as a header, and that header + // never overwrites an authoritative turn: the turn's own revision stays 5 while + // its items move on, so the cache must count arrivals rather than revisions. + const item = turnEvent('turn-a', 0, 6, 'second'); + item.payload.id = 'item/turn-a-tool'; + item.payload.item = { type: 'text', data: { id: 'turn-a-tool', content: 'second', orderIndex: 1, timestamp: 4 } }; + reducer.apply(item); + const after = reducer.messages(); + assert.equal(after[1].items.length, 2, 'the new item is visible'); + assert.notEqual(after[1], before[1], 'the changed turn is rendered again'); + assert.equal(after[3], before[3], 'the untouched turn is still reused'); +}); + +test('a dropped turn stops being cached', () => { + const reducer = new DurableSessionReducer(); + reducer.apply(turnEvent('turn-a', 0, 1, 'first')); + reducer.apply(turnEvent('turn-b', 1, 1, 'second')); + assert.equal(reducer.messages().length, 4); + reducer.apply({ session_id: 'session', event: 'session-record', payload: { + sessionId: 'session', id: 'turn/turn-a', revision: 2, deleted: true } }); + const after = reducer.messages(); + assert.equal(after.length, 2); + assert.equal(after[0].turnId, 'turn-b'); +}); + + +test('history publishes user and assistant together after reduction, including a concurrent model read', async () => { + const { ChatSessionController } = load('ChatSessionController', { './DurableSessionReducer': reducerModule, './InteractionMailboxStore': mailboxModule }); + let callbacks, resolveCatalog; + const snapshots = []; + const manager = { + getModelCatalog: () => new Promise(resolve => { resolveCatalog = resolve; }), + subscribeSession: (_id, next) => { callbacks = next; return { close() {} }; } + }; + const controller = new ChatSessionController(manager, { canPoll: () => true, + onSnapshot: value => snapshots.push(value), onError: error => { throw error; } }); + controller.start('session', { pollVersion: 0, knownMessageCount: 0, knownModelCatalogVersion: 0 }); + await callbacks.onCaughtUp(); + callbacks.onHistoryState(true, false); + callbacks.onHistoryReplay(true); + const count = snapshots.length; + const turn = record(1, '', 'completed'); + turn.payload.id = 'turn/turn'; delete turn.payload.item; delete turn.payload.round; + await callbacks.onEvent(turn); + assert.equal(snapshots.length, count, 'turn header must not expose a lone user bubble'); + resolveCatalog({ version: 1, models: [], default_models: {} }); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(snapshots.length, count, 'model hydration must not publish a partially reduced page'); + await callbacks.onEvent(record(2, 'complete answer', 'completed')); + assert.equal(snapshots.length, count); + callbacks.onHistory(false); + callbacks.onHistoryReplay(false); + callbacks.onHistoryState(false, false); + assert.equal(snapshots.length, count + 1); + assert.deepEqual(snapshots.at(-1).messageSnapshot.map(row => row.text), ['question', 'complete answer']); + assert.equal(snapshots.at(-1).modelCatalog.version, 1); + assert.equal(snapshots.at(-1).historyLoading, false); + await callbacks.onEvent(record(3, 'live update', 'inprogress')); + assert.equal(snapshots.length, count + 2, 'realtime must still publish immediately'); + controller.stop(); +}); diff --git a/src/apps/mobile/ios/OpenBitFun.xcodeproj/project.pbxproj b/src/apps/mobile/ios/OpenBitFun.xcodeproj/project.pbxproj index a54bb4d634..289e38d2d4 100644 --- a/src/apps/mobile/ios/OpenBitFun.xcodeproj/project.pbxproj +++ b/src/apps/mobile/ios/OpenBitFun.xcodeproj/project.pbxproj @@ -55,6 +55,8 @@ A91000000000000000000001 = {isa = PBXBuildFile; fileRef = B91000000000000000000001; }; A90000000000000000000002 /* RemoteHomePresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90000000000000000000002 /* RemoteHomePresentation.swift */; }; A90000000000000000000003 /* StreamingTextState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90000000000000000000003 /* StreamingTextState.swift */; }; + A90000000000000000000005 /* ComposerDismissGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90000000000000000000005 /* ComposerDismissGesture.swift */; }; + A90000000000000000000006 /* HistoryPageArrivalTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90000000000000000000006 /* HistoryPageArrivalTracker.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -113,6 +115,8 @@ B91000000000000000000002 = {isa = PBXFileReference; lastKnownFileType = folder; path = OpenBitFun/Resources/MiniApps; sourceTree = SOURCE_ROOT; }; B90000000000000000000002 /* RemoteHomePresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteHomePresentation.swift; sourceTree = ""; }; B90000000000000000000003 /* StreamingTextState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamingTextState.swift; sourceTree = ""; }; + B90000000000000000000005 /* ComposerDismissGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerDismissGesture.swift; sourceTree = ""; }; + B90000000000000000000006 /* HistoryPageArrivalTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryPageArrivalTracker.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -129,7 +133,7 @@ D10000000000000000000013 /* Settings */ = {isa = PBXGroup; children = (B10000000000000000000032 /* AppSettingsView.swift */); path = Settings; sourceTree = ""; }; D10000000000000000000014 /* Pairing */ = {isa = PBXGroup; children = (B10000000000000000000033 /* PairingSheet.swift */); path = Pairing; sourceTree = ""; }; D10000000000000000000015 /* Account */ = {isa = PBXGroup; children = (B10000000000000000000035 /* AccountSettingsView.swift */); path = Account; sourceTree = ""; }; - D10000000000000000000004 /* Chat */ = {isa = PBXGroup; children = (B90000000000000000000004 /* TimelineScrollController.swift */, B10000000000000000000030 /* ConversationHomeViews.swift */, B10000000000000000000005 /* ConversationHeader.swift */, B10000000000000000000006 /* ChatTimelineView.swift */, B10000000000000000000007 /* ComposerBar.swift */); path = Chat; sourceTree = ""; }; + D10000000000000000000004 /* Chat */ = {isa = PBXGroup; children = (B90000000000000000000006 /* HistoryPageArrivalTracker.swift */, B90000000000000000000004 /* TimelineScrollController.swift */, B10000000000000000000030 /* ConversationHomeViews.swift */, B10000000000000000000005 /* ConversationHeader.swift */, B10000000000000000000006 /* ChatTimelineView.swift */, B10000000000000000000007 /* ComposerBar.swift */); path = Chat; sourceTree = ""; }; D10000000000000000000005 /* Shell */ = {isa = PBXGroup; children = (B9F000000000000000000003, B9F000000000000000000001, B90000000000000000000001 /* StartupBrandReveal.swift */, B10000000000000000000003 /* OpenBitFunTheme.swift */, B10000000000000000000004 /* SidebarView.swift */, B10000000000000000000008 /* MobileShellView.swift */, B10000000000000000000019 /* SessionActionComponents.swift */, B10000000000000000000020 /* RemoteCreateSessionView.swift */, B10000000000000000000021 /* RemoteFilePreviewView.swift */, B10000000000000000000022 /* RemoteSettingsViews.swift */); path = Shell; sourceTree = ""; }; D10000000000000000000011 /* Resources */ = {isa = PBXGroup; children = (B10000000000000000000016 /* Localizable.xcstrings */); path = Resources; sourceTree = ""; }; D10000000000000000000016 /* Presentation */ = {isa = PBXGroup; children = (D10000000000000000000018 /* Models */); path = Presentation; sourceTree = ""; }; @@ -138,7 +142,7 @@ D10000000000000000000009 /* Products */ = {isa = PBXGroup; children = (B10000000000000000000000 /* OpenBitFun.app */, B20000000000000000000000 /* OpenBitFunUITests.xctest */); name = Products; sourceTree = ""; }; D20000000000000000000001 /* OpenBitFunUITests */ = {isa = PBXGroup; children = (B20000000000000000000001 /* RemoteCodeSessionSendUITests.swift */); path = OpenBitFunUITests; sourceTree = ""; }; D10000000000000000000010 /* DesignSystem */ = {isa = PBXGroup; children = (B10000000000000000000013 /* GeneratedMobileDesignTokens.swift */, B10000000000000000000014 /* GeneratedMobilePreviewScenarios.swift */, B10000000000000000000015 /* MobileDesignGallery.swift */, B10000000000000000000018 /* AdaptiveModalComponents.swift */); path = DesignSystem; sourceTree = ""; }; - D10000000000000000000008 /* Infrastructure */ = {isa = PBXGroup; children = (B90000000000000000000003 /* StreamingTextState.swift */, B90000000000000000000002, B10000000000000000000002 /* MobileAppModel.swift */, B10000000000000000000010 /* MobileCoreAdapter.swift */, B10000000000000000000017 /* MobileLocalization.swift */, B10000000000000000000024 /* MobileAppModel+FilePreview.swift */, B10000000000000000000025 /* MobileAppModel+Account.swift */, B10000000000000000000026 /* MobileAppModel+RemoteSession.swift */, B10000000000000000000027 /* MobileAppModel+GeneralChat.swift */, B10000000000000000000028 /* RemoteAuthorityGate.swift */, B10000000000000000000029 /* AccountFailureCopy.swift */, D10000000000000000000017 /* Platform */); path = Infrastructure; sourceTree = ""; }; + D10000000000000000000008 /* Infrastructure */ = {isa = PBXGroup; children = (B90000000000000000000003 /* StreamingTextState.swift */, B90000000000000000000005 /* ComposerDismissGesture.swift */, B90000000000000000000002, B10000000000000000000002 /* MobileAppModel.swift */, B10000000000000000000010 /* MobileCoreAdapter.swift */, B10000000000000000000017 /* MobileLocalization.swift */, B10000000000000000000024 /* MobileAppModel+FilePreview.swift */, B10000000000000000000025 /* MobileAppModel+Account.swift */, B10000000000000000000026 /* MobileAppModel+RemoteSession.swift */, B10000000000000000000027 /* MobileAppModel+GeneralChat.swift */, B10000000000000000000028 /* RemoteAuthorityGate.swift */, B10000000000000000000029 /* AccountFailureCopy.swift */, D10000000000000000000017 /* Platform */); path = Infrastructure; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -154,7 +158,7 @@ C91000000000000000000001 = {isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = (); inputPaths = (); outputPaths = ("$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/MiniApps"); alwaysOutOfDate = 1; runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "export PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\"\nnode \"$SRCROOT/../miniapps/generate.cjs\" ios \"$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/MiniApps\"\n"; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - C10000000000000000000002 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A9F000000000000000000003, A9F000000000000000000002, A9F000000000000000000001, A91000000000000000000001, A90000000000000000000003, A90000000000000000000004, A90000000000000000000002, A90000000000000000000001, A10000000000000000000041, A10000000000000000000040, A10000000000000000000001, A10000000000000000000002, A10000000000000000000003, A10000000000000000000004, A10000000000000000000005, A10000000000000000000006, A10000000000000000000007, A10000000000000000000008, A10000000000000000000010, A10000000000000000000013, A10000000000000000000014, A10000000000000000000015, A10000000000000000000017, A10000000000000000000018, A10000000000000000000019, A10000000000000000000020, A10000000000000000000021, A10000000000000000000022, A10000000000000000000024, A10000000000000000000025, A10000000000000000000026, A10000000000000000000027, A10000000000000000000028, A10000000000000000000029, A10000000000000000000030, A10000000000000000000031, A10000000000000000000032, A10000000000000000000033, A10000000000000000000034, A10000000000000000000035, A10000000000000000000036, A10000000000000000000037, A10000000000000000000038); runOnlyForDeploymentPostprocessing = 0; }; + C10000000000000000000002 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A9F000000000000000000003, A9F000000000000000000002, A9F000000000000000000001, A91000000000000000000001, A90000000000000000000003, A90000000000000000000004, A90000000000000000000002, A90000000000000000000001, A10000000000000000000041, A10000000000000000000040, A10000000000000000000001, A10000000000000000000002, A10000000000000000000003, A10000000000000000000004, A10000000000000000000005, A10000000000000000000006, A10000000000000000000007, A10000000000000000000008, A10000000000000000000010, A10000000000000000000013, A10000000000000000000014, A10000000000000000000015, A10000000000000000000017, A10000000000000000000018, A10000000000000000000019, A10000000000000000000020, A10000000000000000000021, A10000000000000000000022, A10000000000000000000024, A10000000000000000000025, A10000000000000000000026, A10000000000000000000027, A10000000000000000000028, A10000000000000000000029, A10000000000000000000030, A10000000000000000000031, A10000000000000000000032, A10000000000000000000033, A10000000000000000000034, A10000000000000000000035, A10000000000000000000036, A10000000000000000000037, A10000000000000000000038, A90000000000000000000005, A90000000000000000000006); runOnlyForDeploymentPostprocessing = 0; }; C20000000000000000000002 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A20000000000000000000001 /* RemoteCodeSessionSendUITests.swift in Sources */); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ diff --git a/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift b/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift index 2e6ed8d508..2b9dbb1788 100644 --- a/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift +++ b/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift @@ -99,7 +99,7 @@ enum MobileLaunchConfiguration { model.timelineRows = [MobileConversationRow( id: "preview-plan-row", kind: "ASSISTANT", text: "", thinking: nil, images: [], tools: [plan], blocks: [.tools(id: "preview-plan-tools", tools: [plan])], streaming: false, typing: false, - pending: false, showRetry: false, error: nil + showRetry: false, error: nil )] } if arguments.contains("--file-preview") { @@ -349,7 +349,7 @@ private extension MobileAppModel { timelineRows = [ MobileConversationRow( id: userID, kind: "USER", text: "介绍本项目", thinking: nil, - images: [], tools: [], blocks: [], streaming: false, typing: false, pending: false, + images: [], tools: [], blocks: [], streaming: false, typing: false, showRetry: false, error: nil ), MobileConversationRow( @@ -363,7 +363,7 @@ private extension MobileAppModel { ), .tools(id: "preview-tools", tools: [readOne, readTwo, approval, question]), ], - streaming: false, typing: false, pending: false, showRetry: true, + streaming: false, typing: false, showRetry: true, error: "桌面端进程意外退出。" ), ] diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift index e78ec252a7..4688fcef24 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift @@ -13,10 +13,9 @@ struct ChatTimelineView: View { /// is why this one number still has to be passed in. var bottomOverlayInset: CGFloat = 0 @StateObject private var scrollController = TimelineScrollController() - @State private var historyAnchor: (id: String, top: CGFloat, firstID: String)? var body: some View { - ScrollViewReader { proxy in + ScrollViewReader { _ in ScrollView(showsIndicators: false) { VStack(spacing: MobileDesignGeometry.messageSpacing) { // History is already paged by the session store. Measure the @@ -26,23 +25,7 @@ struct ChatTimelineView: View { VStack(spacing: MobileDesignGeometry.messageSpacing) { if model.surface == .remote && model.remoteHasMoreMessages { Button { - #if DEBUG - Logger(subsystem: "com.openbitfun.mobile.ios", category: "timeline-scroll").info("History capture frames=\(scrollController.rowFrames.count)") - #endif - if let row = scrollController.rowFrames.filter({ $0.value.maxY > 0 }) - .min(by: { $0.value.minY < $1.value.minY }) - { - historyAnchor = (row.key, row.value.minY, model.timelineRows.first?.id ?? "") - #if DEBUG - Logger(subsystem: "com.openbitfun.mobile.ios", category: "timeline-scroll").info("History capture top=\(row.value.minY) height=\(row.value.height)") - #endif - } - scrollController.stopFollowing() - if let onLoadOlderMessages { - onLoadOlderMessages() - } else { - model.loadOlderRemoteMessages() - } + requestOlderHistoryPage() } label: { HStack(spacing: 7) { if model.remoteHistoryLoading { ProgressView().controlSize(.small) } @@ -70,58 +53,38 @@ struct ChatTimelineView: View { } .padding(.horizontal, MobileDesignGeometry.contentGutter) .padding(.top, MobileDesignGeometry.timelineTopPadding) - .padding(.bottom, 14) + .padding(.bottom, 14 + scrollController.historyBottomSpace) .background(TimelineScrollProbe(controller: scrollController)) } .coordinateSpace(name: "chat-timeline") .onPreferenceChange(TimelineRowFramesKey.self) { frames in scrollController.rowFrames = frames - // Apply corrections from this measurement directly. Publishing - // frames into State adds a second, potentially stale layout pass. - guard let anchor = historyAnchor else { return } - if scrollController.isUserScrolling { - historyAnchor = nil - } else if historyRestoreRequest != nil, model.timelineRows.first?.id != anchor.firstID, - let frame = frames[anchor.id] - { - // Preserve the actual visible row, including its partial offset, - // rather than guessing from a lazy stack's total height. - scrollController.preserveAnchor(displacement: frame.minY - anchor.top) - } + scrollController.restoreHistoryAnchor() } - .task(id: historyRestoreRequest) { - #if DEBUG - Logger(subsystem: "com.openbitfun.mobile.ios", category: "timeline-scroll").info("History task available=\(historyRestoreRequest != nil)") - #endif - guard let request = historyRestoreRequest else { return } - await Task.yield() - guard !Task.isCancelled, !scrollController.isUserScrolling, - historyRestoreRequest == request - else { return } - // Explicitly key restoration by both the captured anchor and the - // new transcript. State used only inside callbacks may otherwise - // arrive after the list's first layout notification. - proxy.scrollTo(scrollTargetID(request.id), anchor: .top) - await Task.yield() - guard !Task.isCancelled, historyRestoreRequest == request else { return } - if let frame = scrollController.rowFrames[request.id] { - scrollController.preserveAnchor(displacement: frame.minY - request.top) - } + .onChange(of: model.remoteHistoryLoading) { loading in + scrollController.historyLoadingChanged(loading) } .scrollDismissesKeyboard(.interactively) - .onAppear { scrollController.open(session: model.selectedSessionID) } + .onAppear { + scrollController.open(session: model.selectedSessionID) + // Reaching the start of the loaded transcript asks for the next + // page by itself; the row stays as the loading and retry state. + // Layout changes and busy gestures do not queue another page. + scrollController.onHistoryStartReached = { + guard canRequestOlderHistoryPage else { return false } + requestOlderHistoryPage() + return true + } + } .onChange(of: model.selectedSessionID) { session in - historyAnchor = nil scrollController.open(session: session) } .onChange(of: model.composerSendGeneration) { _ in - historyAnchor = nil scrollController.followBottom() } .overlay(alignment: .bottomTrailing) { if !scrollController.followsBottom { Button { - historyAnchor = nil scrollController.followBottom() } label: { Image(systemName: "chevron.down") @@ -144,22 +107,6 @@ struct ChatTimelineView: View { } } - private struct HistoryRestoreRequest: Equatable { - let session: String - let firstID: String - let id: String - let top: CGFloat - } - - private var historyRestoreRequest: HistoryRestoreRequest? { - guard let anchor = historyAnchor, let first = model.timelineRows.first?.id, - first != anchor.firstID - else { return nil } - return HistoryRestoreRequest( - session: model.selectedSessionID, firstID: first, - id: anchor.id, top: anchor.top) - } - private var currentTurnStart: Int { if let userIndex = model.timelineRows.lastIndex(where: { $0.kind == "USER" }) { return userIndex @@ -195,6 +142,33 @@ struct ChatTimelineView: View { model.timelineRows.prefix(currentTurnStart) } + /// Whether the store would accept another page right now. + /// + /// A failed page stays a tap on the row: an automatic retry would keep + /// asking a host that has already said no, and the row is on screen saying so. + private var canRequestOlderHistoryPage: Bool { + model.surface == .remote && (onLoadOlderMessages != nil || model.remoteConnected) && model.remoteHasMoreMessages + && !model.remoteHistoryLoading && !model.remoteHistoryFailed && !model.busy + } + + /// The one place a history page is asked for, from the row and from arriving + /// at the start of the loaded transcript. + /// + /// The anchor is captured before the request so the page that lands above the + /// reader does not move what they were reading, and following the bottom is + /// dropped so a page arriving cannot drag the viewport away from it. + private func requestOlderHistoryPage() { + guard onLoadOlderMessages != nil || model.remoteConnected, + model.remoteHasMoreMessages, !model.busy, !model.remoteHistoryLoading, + scrollController.beginHistoryRequest() else { return } + if let onLoadOlderMessages { + onLoadOlderMessages() + scrollController.historyLoadingChanged(model.remoteHistoryLoading) + } else { + model.loadOlderRemoteMessages() + } + } + private func timelineRow(_ row: MobileConversationRow, identity: String? = nil) -> some View { ConversationRowView(row: row, model: model, language: model.appLanguage.rawValue) .equatable().id(identity ?? row.id) @@ -317,11 +291,6 @@ private struct ConversationRowView: View, Equatable { .background(OpenBitFunTheme.soft) .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.messageBubbleRadius)) } - if row.pending { - Text(model.localized("正在发送")) - .font(MobileDesignTypography.labelSmall.font) - .foregroundStyle(OpenBitFunTheme.muted) - } if row.showRetry { Button { model.retryMessage(row.text, images: row.images) } label: { Label(model.localized("重新发送"), systemImage: "arrow.clockwise") @@ -1092,15 +1061,9 @@ private struct TimelineImageGrid: View { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 7) { ForEach(images) { image in Button { selected = image } label: { - if let uiImage = image.uiImage { - Image(uiImage: uiImage).resizable().scaledToFill() - .frame(height: images.count == 1 ? 180 : 112).frame(maxWidth: .infinity) - .clipped().clipShape(RoundedRectangle(cornerRadius: 14)) - } else { - Image(systemName: "photo").foregroundStyle(OpenBitFunTheme.muted) - .frame(maxWidth: .infinity, minHeight: 112).background(OpenBitFunTheme.soft) - .clipShape(RoundedRectangle(cornerRadius: 14)) - } + AsyncDecodedImage(dataURL: image.dataURL, fill: true) + .frame(height: images.count == 1 ? 180 : 112).frame(maxWidth: .infinity) + .clipped().clipShape(RoundedRectangle(cornerRadius: 14)) } .buttonStyle(.plain) } @@ -1116,7 +1079,7 @@ private struct FullScreenTimelineImage: View { var body: some View { ZStack(alignment: .topTrailing) { OpenBitFunTheme.mediaBackground.ignoresSafeArea() - if let uiImage = image.uiImage { Image(uiImage: uiImage).resizable().scaledToFit().ignoresSafeArea() } + AsyncDecodedImage(dataURL: image.dataURL).ignoresSafeArea() Button { dismiss() } label: { Image(systemName: "xmark").font(.system(size: 15, weight: .semibold)).foregroundStyle(OpenBitFunTheme.contentOnAction) .frame(width: 44, height: 44).background(OpenBitFunTheme.mediaControlBackground).clipShape(Circle()) @@ -1126,10 +1089,42 @@ private struct FullScreenTimelineImage: View { } } -private extension MobileTimelineImage { - var uiImage: UIImage? { - guard let marker = dataURL.range(of: "base64,") else { return nil } - return Data(base64Encoded: String(dataURL[marker.upperBound...])).flatMap(UIImage.init(data:)) +/// Decode once off the UI executor, and discard results after source changes. +struct AsyncDecodedImage: View { + var data: Data? = nil + var dataURL: String? = nil + var fill = false + @State private var image: UIImage? + @State private var loading = true + private struct Source: Equatable { let data: Data?; let url: String? } + var body: some View { + Group { + if let image { + Image(uiImage: image).resizable().aspectRatio(contentMode: fill ? .fill : .fit) + } else if loading { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + Image(systemName: "photo").foregroundStyle(OpenBitFunTheme.muted) + } + } + .task(id: Source(data: data, url: dataURL)) { + image = nil + loading = true + let bytes = data + let url = dataURL + let decoded = await Task.detached(priority: .userInitiated) { + let source: Data? + if let bytes { source = bytes } + else if let url, let marker = url.range(of: "base64,") { + source = Data(base64Encoded: String(url[marker.upperBound...])) + } else { source = nil } + guard let source, let original = UIImage(data: source) else { return nil as UIImage? } + return original.preparingForDisplay() ?? original + }.value + guard !Task.isCancelled else { return } + image = decoded + loading = false + } } } diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift index 8b6aa4b222..1ca65133b4 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift @@ -18,6 +18,8 @@ struct ComposerBar: View { @Environment(\.scenePhase) private var scenePhase @FocusState private var focused: Bool @StateObject private var speech = SpeechInputController() + @State private var inputExpansionRequested = false + @State private var photoPickerOpen = false @State private var pickerItems: [PhotosPickerItem] = [] @State private var modelSelectorOpen = ProcessInfo.processInfo.arguments.contains( "--composer-model-picker" @@ -30,7 +32,7 @@ struct ComposerBar: View { } private var expanded: Bool { - focused || modelSelectorOpen || model.draft.contains("\n") + inputExpansionRequested || focused || modelSelectorOpen || model.draft.contains("\n") } private var hasContent: Bool { @@ -97,7 +99,13 @@ struct ComposerBar: View { .padding(.bottom, 14) .animation(.easeOut(duration: 0.22), value: expanded) .animation(.easeOut(duration: 0.18), value: model.composerImages.count) + .photosPicker(isPresented: $photoPickerOpen, selection: $pickerItems, + maxSelectionCount: max(1, 4 - model.composerImages.count), matching: .images) + .onChange(of: focused) { value in + inputExpansionRequested = value + } .onChange(of: model.composerSendGeneration) { _ in + inputExpansionRequested = false focused = false modelSelectorOpen = false } @@ -185,6 +193,19 @@ struct ComposerBar: View { .frame(minHeight: expanded ? MobileDesignGeometry.composerExpandedInputRowHeight : MobileDesignGeometry.composerCollapsedHeight) + // Dragging the input row down dismisses the keyboard, matching the + // Messages and WeChat composers. A simultaneous gesture keeps the text + // field's own tap, caret, and vertical scrolling intact. + .simultaneousGesture( + DragGesture(minimumDistance: ComposerDismissGesture.minimumDistance).onChanged { value in + guard ComposerDismissGesture.dismissesKeyboard( + translation: value.translation, + isFocused: focused + ) else { return } + inputExpansionRequested = false + focused = false + } + ) } private var expandedActionRow: some View { @@ -234,6 +255,9 @@ struct ComposerBar: View { .foregroundStyle(OpenBitFunTheme.ink) .lineLimit(1...maxLines) .focused($focused) + // Expand from the tap itself; keyboard/first-responder startup is + // independent and must not gate the local composer affordances. + .simultaneousGesture(TapGesture().onEnded { inputExpansionRequested = true }) .accessibilityIdentifier("composer.input") .submitLabel(.send) .onSubmit { @@ -256,13 +280,7 @@ struct ComposerBar: View { @ViewBuilder private var attachmentAction: some View { if model.composerImages.count < 4 { - PhotosPicker( - selection: $pickerItems, - maxSelectionCount: 4 - model.composerImages.count, - matching: .images - ) { - plusGlyph - } + Button { photoPickerOpen = true } label: { plusGlyph } .buttonStyle(.plain) .accessibilityLabel(Text(model.localized("添加图片"))) } else { @@ -281,20 +299,24 @@ struct ComposerBar: View { } private var primaryAction: some View { - Button(action: performPrimaryAction) { + // Resolve once for this render. Button's deferred label closure and its + // disabled modifier must use the same state, including after keyboard + // focus changes and asynchronous connection updates. + let action = primaryActionKind + return Button(action: performPrimaryAction) { ZStack { - switch primaryActionKind { + switch action { case .send, .sendBlocked: Image(systemName: "arrow.up") .font(.system(size: 17, weight: .bold)) .foregroundStyle( - primaryActionKind == .send + action == .send ? OpenBitFunTheme.contentOnAction : OpenBitFunTheme.muted ) .frame(width: 32, height: 32) .background( - primaryActionKind == .send + action == .send ? MobileDesignColors.primaryAction : OpenBitFunTheme.soft ) @@ -312,7 +334,7 @@ struct ComposerBar: View { width: 16, height: 19, color: - primaryActionKind == .voice + action == .voice ? OpenBitFunTheme.ink : OpenBitFunTheme.muted.opacity(0.38) ) @@ -322,11 +344,12 @@ struct ComposerBar: View { width: MobileDesignGeometry.composerActionSize, height: MobileDesignGeometry.composerActionSize ) - .background(primaryActionKind == .voiceBlocked ? OpenBitFunTheme.soft : OpenBitFunTheme.transparent) + .background(action == .voiceBlocked ? OpenBitFunTheme.soft : OpenBitFunTheme.transparent) .clipShape(Circle()) } .buttonStyle(.plain) - .disabled(primaryActionKind == .sendBlocked || primaryActionKind == .voiceBlocked) + .disabled(action == .sendBlocked || action == .voiceBlocked) + .accessibilityIdentifier("composer.primaryAction") .accessibilityLabel(primaryActionLabel) } @@ -363,16 +386,7 @@ struct ComposerBar: View { HStack(spacing: 8) { ForEach(model.composerImages) { attachment in ZStack(alignment: .topTrailing) { - Group { - if let image = UIImage(data: attachment.data) { - Image(uiImage: image) - .resizable() - .scaledToFill() - } else { - Image(systemName: "photo") - .foregroundStyle(OpenBitFunTheme.muted) - } - } + AsyncDecodedImage(data: attachment.data, fill: true) .frame(width: 64, height: 64) .background(OpenBitFunTheme.soft) .clipShape(RoundedRectangle(cornerRadius: 12)) @@ -437,8 +451,8 @@ struct ComposerBar: View { LazyVStack(spacing: MobileDesignGeometry.composerModelSelectorRowGap) { ForEach(selectorModels) { option in Button { - model.selectModel(option.id) modelSelectorOpen = false + model.selectModel(option.id) } label: { HStack(spacing: 10) { Image(systemName: option.selected ? "checkmark.circle" : "circle") @@ -511,6 +525,7 @@ struct ComposerBar: View { private func submitMessage() { guard model.send() else { return } speech.stop() + inputExpansionRequested = false focused = false modelSelectorOpen = false } diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/HistoryPageArrivalTracker.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/HistoryPageArrivalTracker.swift new file mode 100644 index 0000000000..401ad60c25 --- /dev/null +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/HistoryPageArrivalTracker.swift @@ -0,0 +1,16 @@ +import Foundation + +/// One automatic page per deliberate drag. Layout and bounce cannot re-arm it. +struct HistoryPageArrivalTracker { + private var consumed = true + + mutating func beginGesture() { consumed = false } + + mutating func arrived(atStart: Bool) -> Bool { + guard atStart, !consumed else { return false } + consumed = true + return true + } + + mutating func cancelArrival() { consumed = true } +} diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/TimelineScrollController.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/TimelineScrollController.swift index 4fd2a6e9c4..f7314e4959 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/TimelineScrollController.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/TimelineScrollController.swift @@ -7,13 +7,18 @@ import OSLog @MainActor final class TimelineScrollController: ObservableObject { @Published private(set) var followsBottom = true + @Published private(set) var historyBottomSpace: CGFloat = 0 + /// Asked once per deliberate drag reaching the start of the transcript. + var onHistoryStartReached: (() -> Bool)? + private var historyArrival = HistoryPageArrivalTracker() private weak var scrollView: UIScrollView? private var observations: [NSKeyValueObservation] = [] private var updateScheduled = false private var applyingOffset = false private var interactionRevision = 0 - private var anchorScheduled = false - private var pendingAnchorDisplacement: CGFloat = 0 + private var historyAnchor: (id: String, contentTop: CGFloat)? + private var historyRequestInFlight = false + private var requestedDuringGesture = false private var sessionID = "" // Geometry telemetry must not invalidate the SwiftUI tree on every scroll pixel. private final class WeakRow { @@ -74,16 +79,19 @@ final class TimelineScrollController: ObservableObject { func attach(_ scroll: UIScrollView) { guard scrollView !== scroll else { return } + scrollView?.panGestureRecognizer.removeTarget(self, action: #selector(historyPanChanged(_:))) observations.removeAll() scrollView = scroll + scroll.panGestureRecognizer.addTarget(self, action: #selector(historyPanChanged(_:))) previousSize = scroll.bounds.size observations = [ - scroll.observe(\.contentSize, options: [.new]) { [weak self] _, _ in + scroll.observe(\.contentSize, options: [.new]) { [weak self] scroll, _ in MainActor.assumeIsolated { #if DEBUG self?.traceGeometry("content-size") #endif self?.scheduleFollow() + self?.restoreHistoryAnchor() } }, scroll.observe(\.bounds, options: [.new]) { [weak self] scroll, _ in @@ -97,54 +105,88 @@ final class TimelineScrollController: ObservableObject { self.traceGeometry("bounds") #endif self.observeUserScroll(scroll) + self.restoreHistoryAnchor() } } ] logGeometry("attach", force: true) scheduleFollow() + restoreHistoryAnchor() } func open(session: String) { guard sessionID != session else { return } sessionID = session + historyArrival.cancelArrival() + historyRequestInFlight = false + requestedDuringGesture = false followBottom() } func followBottom() { interactionRevision += 1 - pendingAnchorDisplacement = 0 + historyAnchor = nil + historyBottomSpace = 0 followsBottom = true logGeometry("follow-request", force: true) scheduleFollow() } - var isUserScrolling: Bool { - guard let scroll = scrollView else { return false } - // Tracking also includes a stationary tap on a button inside the list. - // Only an actual drag/deceleration cancels history-position restoration. - return scroll.isDragging || scroll.isDecelerating + func stopFollowing() { interactionRevision += 1; followsBottom = false } + + /// Capture content coordinates, not a viewport position. Finger movement + /// changes only the viewport; prepending changes only the row's content Y. + @discardableResult + func beginHistoryRequest() -> Bool { + guard !historyRequestInFlight, let scroll = scrollView else { return false } + historyRequestInFlight = true + requestedDuringGesture = true + stopFollowing() + // A short transcript leaves blank space below its rows. Retain that + // space while prepending, otherwise UIScrollView clamps the compensating + // offset and pushes the original messages down on a tall viewport. + historyBottomSpace += max(0, scroll.bounds.height - scroll.adjustedContentInset.top + - scroll.adjustedContentInset.bottom - scroll.contentSize.height) + if let row = rowFrames.filter({ $0.value.maxY > 0 }) + .min(by: { $0.value.minY < $1.value.minY }) { + historyAnchor = (row.key, row.value.minY + scroll.contentOffset.y) + } + return true } - func stopFollowing() { interactionRevision += 1; followsBottom = false } + func historyLoadingChanged(_ loading: Bool) { + historyRequestInFlight = loading + restoreHistoryAnchor() + } - func preserveAnchor(displacement: CGFloat) { - guard !isUserScrolling, !followsBottom, abs(displacement) > 0.5 else { return } - pendingAnchorDisplacement = displacement - guard !anchorScheduled else { return } - anchorScheduled = true - let revision = interactionRevision - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.anchorScheduled = false - guard revision == self.interactionRevision, !self.isUserScrolling, - !self.followsBottom, let scroll = self.scrollView else { return } - let delta = self.pendingAnchorDisplacement - self.pendingAnchorDisplacement = 0 - self.applyingOffset = true - scroll.setContentOffset(CGPoint(x: scroll.contentOffset.x, - y: scroll.contentOffset.y + delta), animated: false) - self.applyingOffset = false + /// Run during native layout so the inserted area is compensated before it + /// is drawn. This also works while dragging/decelerating: it never restores + /// an old finger position or scrolls the captured row to the top first. + func restoreHistoryAnchor() { + guard !applyingOffset, !followsBottom, let anchor = historyAnchor, + let scroll = scrollView, let view = rowViews[anchor.id]?.view, + view.window != nil else { return } + let contentTop = view.convert(view.bounds, to: scroll).minY + let delta = contentTop - anchor.contentTop + guard abs(delta) > 0.5 else { return } + historyAnchor = (anchor.id, contentTop) + applyingOffset = true + scroll.setContentOffset(CGPoint(x: scroll.contentOffset.x, + y: scroll.contentOffset.y + delta), animated: false) + applyingOffset = false + } + + @objc private func historyPanChanged(_ pan: UIPanGestureRecognizer) { + guard let scroll = scrollView else { return } + if pan.state == .began { + requestedDuringGesture = historyRequestInFlight + historyArrival.beginGesture() + // A completed page no longer owns subsequent deliberate scrolling. + if !historyRequestInFlight { historyAnchor = nil } } + guard pan.state == .began || pan.state == .changed, + !requestedDuringGesture, !historyRequestInFlight else { return } + observeHistoryStart(scroll) } private func observeUserScroll(_ scroll: UIScrollView) { @@ -155,7 +197,7 @@ final class TimelineScrollController: ObservableObject { // KVO can run inside a layout pass; publish presentation state on the next turn. DispatchQueue.main.async { [weak self] in guard let self, self.interactionRevision == revision else { return } - self.followsBottom = atBottom + if !self.historyRequestInFlight { self.followsBottom = atBottom } } } @@ -164,6 +206,19 @@ final class TimelineScrollController: ObservableObject { scroll.contentSize.height - scroll.bounds.height + scroll.adjustedContentInset.bottom) } + /// Only real pan events may request a page. Layout, bounce and anchor + /// corrections cannot re-arm pagination or drain history after release. + private func observeHistoryStart(_ scroll: UIScrollView) { + let aboveStart = scroll.contentOffset.y + scroll.adjustedContentInset.top + guard historyArrival.arrived(atStart: aboveStart <= 0.5) else { return } + let session = sessionID + requestedDuringGesture = true + DispatchQueue.main.async { [weak self] in + guard let self, self.sessionID == session, !self.historyRequestInFlight else { return } + _ = self.onHistoryStartReached?() + } + } + private func scheduleFollow() { guard !updateScheduled else { return } updateScheduled = true @@ -201,7 +256,8 @@ struct TimelineRowProbe: UIViewRepresentable { let rowID: String func makeUIView(context: Context) -> UIView { - let view = UIView() + let view = RowView() + view.controller = controller view.isUserInteractionEnabled = false controller.registerRow(rowID, view: view) return view @@ -210,6 +266,14 @@ struct TimelineRowProbe: UIViewRepresentable { func updateUIView(_ view: UIView, context: Context) { controller.registerRow(rowID, view: view) } + + final class RowView: UIView { + weak var controller: TimelineScrollController? + override func layoutSubviews() { + super.layoutSubviews() + controller?.restoreHistoryAnchor() + } + } } struct TimelineScrollProbe: UIViewRepresentable { @@ -230,7 +294,11 @@ struct TimelineScrollProbe: UIViewRepresentable { final class ProbeView: UIView { weak var controller: TimelineScrollController? override func didMoveToWindow() { super.didMoveToWindow(); bindScrollView() } - override func layoutSubviews() { super.layoutSubviews(); bindScrollView() } + override func layoutSubviews() { + super.layoutSubviews() + bindScrollView() + controller?.restoreHistoryAnchor() + } func bindScrollView() { var parent = superview diff --git a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/MobileDesignGallery.swift b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/MobileDesignGallery.swift index feff6b7b2d..805cfc1dbe 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/MobileDesignGallery.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/MobileDesignGallery.swift @@ -91,6 +91,7 @@ struct StreamingRegressionView: View { @State private var finished = false @State private var olderCount = 0 @State private var historyCount = 40 + @State private var historyRequests = 0 var body: some View { VStack(spacing: 0) { @@ -135,15 +136,33 @@ struct StreamingRegressionView: View { }.accessibilityIdentifier("fixture.finishLoading") } } + if ProcessInfo.processInfo.arguments.contains("--history-pagination-regression") { + Text("History requests: \(historyRequests)").accessibilityIdentifier("fixture.historyRequests") + Button("Deliver history") { + Task { @MainActor in + for _ in 0..<3 { + olderCount += 4 + model.timelineRows = history + try? await Task.sleep(nanoseconds: 150_000_000) + } + model.remoteHistoryLoading = false + } + }.accessibilityIdentifier("fixture.deliverHistory") + } Text(finished ? "Stream finished" : "Stream fixture") .accessibilityIdentifier("fixture.status") if ProcessInfo.processInfo.arguments.contains("--fixture-shell") { MobileShellView(model: model) } else { ChatTimelineView(model: model, onLoadOlderMessages: { - olderCount += 10 - model.timelineRows = history - model.remoteHasMoreMessages = false + if ProcessInfo.processInfo.arguments.contains("--history-pagination-regression") { + historyRequests += 1 + model.remoteHistoryLoading = true + } else { + olderCount += 10 + model.timelineRows = history + model.remoteHasMoreMessages = false + } }) ComposerBar(model: model) } @@ -171,9 +190,9 @@ struct StreamingRegressionView: View { func user(_ id: String) -> MobileConversationRow { MobileConversationRow(id: id, kind: "USER", text: "SENT-USER-BUBBLE", thinking: nil, images: [], tools: [], blocks: [], streaming: false, - typing: false, pending: id == "pending-user", showRetry: false, error: nil) + typing: false, showRetry: false, error: nil) } - model.timelineRows = history + [user("pending-user"), row(id: "live", text: "", live: true)] + model.timelineRows = history + [user("optimistic-user"), row(id: "live", text: "", live: true)] do { try await Task.sleep(nanoseconds: 2_000_000_000) } catch { return } model.timelineRows = history + [user("confirmed-user"), row(id: "live", text: "", live: true)] do { try await Task.sleep(nanoseconds: 5_000_000_000) } catch { return } @@ -233,7 +252,7 @@ struct StreamingRegressionView: View { .thinking(id: "child-live", text: "LIVE-CHILD-THOUGHT", streaming: true) ], status: generation == 0 ? "running" : "failed"), .subagent(id: "empty", title: "EMPTY-TASK", running: false, text: "", children: [], status: "timeout") - ], streaming: false, typing: false, pending: false, showRetry: false, error: nil) + ], streaming: false, typing: false, showRetry: false, error: nil) } return MobileConversationRow(id: "cards", kind: "ASSISTANT", text: "", thinking: nil, images: [], tools: [], blocks: [ @@ -246,7 +265,7 @@ struct StreamingRegressionView: View { .tools(id: "running", tools: [tool("running", phase: "RUNNING")]), .tools(id: "failed", tools: [tool("failed", phase: "FAILED")]), .text(id: "answer", text: "ANSWER-AFTER-ACTIVITY", streaming: false) - ], streaming: false, typing: false, pending: false, showRetry: false, error: nil) + ], streaming: false, typing: false, showRetry: false, error: nil) } private func thinkingRow(thought: String, answer: String, live: Bool) -> MobileConversationRow { @@ -254,7 +273,7 @@ struct StreamingRegressionView: View { if !answer.isEmpty { blocks.append(.text(id: "answer", text: answer, streaming: live)) } return MobileConversationRow(id: live ? "thinking-live" : "thinking-final", kind: "ASSISTANT", text: answer, thinking: thought, images: [], tools: [], blocks: blocks, streaming: live, typing: false, - pending: false, showRetry: false, error: nil, live: live) + showRetry: false, error: nil, live: live) } private var history: [MobileConversationRow] { @@ -265,7 +284,7 @@ struct StreamingRegressionView: View { private func row(id: String, text: String, live: Bool) -> MobileConversationRow { MobileConversationRow(id: id, kind: "ASSISTANT", text: text, thinking: nil, images: [], tools: [], blocks: [], streaming: live, typing: false, - pending: false, showRetry: false, error: nil, live: live) + showRetry: false, error: nil, live: live) } } #endif diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/MiniAppsView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/MiniAppsView.swift index 0e73d9bae4..1a01c6f7de 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/MiniAppsView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/MiniAppsView.swift @@ -95,7 +95,7 @@ private struct MiniAppsView: View { if let failure { Text(failure).font(MobileDesignTypography.bodyMedium.font) .foregroundStyle(OpenBitFunTheme.muted).padding(.vertical, 16) - Button(model.localized("重试")) { loadApps() } + Button(model.localized("重试")) { Task { await loadApps() } } Spacer() } else { ScrollView { @@ -141,16 +141,20 @@ private struct MiniAppsView: View { } .foregroundStyle(OpenBitFunTheme.ink) .background(OpenBitFunTheme.page.ignoresSafeArea()) - .task { loadApps() } + .task { await loadApps() } } - private func loadApps() { + private func loadApps() async { failure = nil do { guard let url = Bundle.main.url(forResource: "catalog", withExtension: "json", subdirectory: "MiniApps") else { throw CocoaError(.fileNoSuchFile) } - apps = try JSONDecoder().decode([BuiltinMiniApp].self, from: Data(contentsOf: url)) + let loaded = try await Task.detached(priority: .userInitiated) { + try JSONDecoder().decode([BuiltinMiniApp].self, from: Data(contentsOf: url)) + }.value + guard !Task.isCancelled else { return } + apps = loaded } catch { failure = model.localized("无法加载小应用,请重试") } } } @@ -167,14 +171,20 @@ private struct MiniAppWebView: UIViewRepresentable { let web = WKWebView(frame: .zero, configuration: configuration) web.navigationDelegate = context.coordinator context.coordinator.web = web - if let url = Bundle.main.url(forResource: "\(appID).\(locale)", withExtension: "html", subdirectory: "MiniApps"), - let html = try? String(contentsOf: url, encoding: .utf8) { - web.loadHTMLString(html, baseURL: URL(string: "https://miniapp.local/")) + if let url = Bundle.main.url(forResource: "\(appID).\(locale)", withExtension: "html", subdirectory: "MiniApps") { + context.coordinator.loadTask = Task { @MainActor [weak web] in + let html = await Task.detached(priority: .userInitiated) { + try? String(contentsOf: url, encoding: .utf8) + }.value + guard !Task.isCancelled, let html else { return } + web?.loadHTMLString(html, baseURL: URL(string: "https://miniapp.local/")) + } } return web } func updateUIView(_ uiView: WKWebView, context: Context) {} static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) { + coordinator.loadTask?.cancel() uiView.stopLoading() uiView.configuration.userContentController.removeScriptMessageHandler(forName: "miniappNative") uiView.navigationDelegate = nil @@ -183,6 +193,7 @@ private struct MiniAppWebView: UIViewRepresentable { final class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate { let appID: String weak var web: WKWebView? + var loadTask: Task? private static let storageQueue = DispatchQueue(label: "com.openbitfun.miniapps.storage") init(appID: String) { self.appID = appID } func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteFilePreviewView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteFilePreviewView.swift index 163ebdca81..0ffc76f865 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteFilePreviewView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteFilePreviewView.swift @@ -230,11 +230,9 @@ struct RemoteFilePreviewSheet: View { } .foregroundStyle(OpenBitFunTheme.muted).padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let data = preview.imageData, let image = UIImage(data: data) { + } else if let data = preview.imageData { ScrollView([.horizontal, .vertical], showsIndicators: false) { - Image(uiImage: image) - .resizable() - .scaledToFit() + AsyncDecodedImage(data: data) .padding(18) } } else { diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/ComposerDismissGesture.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/ComposerDismissGesture.swift new file mode 100644 index 0000000000..276f50cc60 --- /dev/null +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/ComposerDismissGesture.swift @@ -0,0 +1,23 @@ +import CoreGraphics + +/// A downward drag on the composer input row dismisses the keyboard. +/// +/// The transcript's own scroll view already dismisses the keyboard +/// interactively, but a short conversation has nothing to scroll, and the +/// composer row sits outside that scroll view entirely — which is why dragging +/// the input row had no effect. Matching the Messages and WeChat composers, only +/// an intentional and mostly vertical drag counts, so the text field keeps its +/// tap, caret placement and horizontal scrolling. +enum ComposerDismissGesture { + /// Long enough to exclude a tap or a caret drag, short enough to feel immediate. + static let minimumDistance: CGFloat = 12 + static let threshold: CGFloat = 36 + /// Vertical drags win over the sideways travel they are usually mixed with. + private static let verticalBias: CGFloat = 1.5 + + static func dismissesKeyboard(translation: CGSize, isFocused: Bool) -> Bool { + guard isFocused else { return false } + return translation.height > threshold + && translation.height > abs(translation.width) * verticalBias + } +} diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift index 12644cb101..8ecff9c318 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift @@ -83,7 +83,6 @@ extension MobileAppModel { blocks: [], streaming: false, typing: false, - pending: false, showRetry: false, error: nil ) @@ -102,7 +101,6 @@ extension MobileAppModel { blocks: row.blocks.map(mapBlock), streaming: row.streaming, typing: row.typing, - pending: row.pending, showRetry: row.showRetry, error: row.error, live: row.live diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift index 8dfef07f1c..d92212fe5e 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift @@ -784,6 +784,11 @@ extension MobileAppModel { } func loadOlderRemoteMessages() { + // A rejected tap used to be invisible: the store's own gates decide + // whether a load starts, so state the inputs next to the request. + #if DEBUG + mobilePerformanceLog.info("Load older requested surface=\(String(describing: self.surface), privacy: .public) connected=\(self.remoteConnected) has_more=\(self.remoteHasMoreMessages) busy=\(self.busy) loading=\(self.remoteHistoryLoading)") + #endif guard surface == .remote, remoteConnected, remoteHasMoreMessages, !busy else { return } coreAdapter?.loadOlderRemoteMessages() } @@ -950,6 +955,16 @@ extension MobileAppModel { expectedEpoch: remoteTargetEpoch ) else { return } guard let ready = state as? RemoteSessionUiStateReady else { + if let failed = state as? RemoteSessionUiStateFailed, + RemoteSessionFailureProjectionPolicy.keepsVisibleConversation(reasonName: failed.reason.name) { + // A dropped transport is not a lost conversation. The store keeps + // polling and republishes the transcript on its next successful + // response, so the projection stays exactly where it is and only + // the connection phase reports the interruption. Clearing here + // would discard a conversation the store never considered lost. + setPublishedIfChanged(\.busy, to: false) + return + } permissionMailbox = nil remoteOpenedSessionID = nil remoteInitialSessionReady = false @@ -1091,7 +1106,7 @@ extension MobileAppModel { let users = projectedRows.filter { $0.kind == "USER" } let previousUsers = timelineRows.filter { $0.kind == "USER" } let removedUsers = Set(previousUsers.map(\.id)).subtracting(users.map(\.id)).count - mobilePerformanceLog.info("Timeline projection rows=\(projectedRows.count) user_rows=\(users.count) previous_user_rows=\(previousUsers.count) removed_user_ids=\(removedUsers) pending_users=\(users.filter(\.pending).count) live_rows=\(projectedRows.filter(\.live).count) blocks=\(projectedRows.reduce(0) { $0 + $1.blocks.count }) busy=\(ready.busy)") + mobilePerformanceLog.info("Timeline projection rows=\(projectedRows.count) user_rows=\(users.count) previous_user_rows=\(previousUsers.count) removed_user_ids=\(removedUsers) live_rows=\(projectedRows.filter(\.live).count) blocks=\(projectedRows.reduce(0) { $0 + $1.blocks.count }) busy=\(ready.busy)") #if DEBUG if users.map(\.id) != previousUsers.map(\.id) { let identities = timeline.persistedMessages.filter { $0.role == "user" }.map { diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/RemoteAuthorityGate.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/RemoteAuthorityGate.swift index 08f62915ea..f2b87e786a 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/RemoteAuthorityGate.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/RemoteAuthorityGate.swift @@ -292,3 +292,23 @@ enum ComposerSendSettlementPolicy { !acknowledged && sentSession == currentSession && draftIsEmpty && attachmentsAreEmpty } } + +/// Whether a failed remote state ends the conversation or only interrupts it. +/// +/// The shared store retries a transport-class failure without discarding its +/// transcript, and publishes `Failed` for those reasons only when it has no +/// ready snapshot to hand over yet. That is a cold open or a just-rebound +/// target, not a lost conversation, so the projection must survive the blip and +/// let the connection state alone report the interruption. A deterministic +/// failure — the session is gone, the host cannot stream, the command was +/// refused — still ends the projection. +enum RemoteSessionFailureProjectionPolicy { + static func keepsVisibleConversation(reasonName: String) -> Bool { + // Mirrors the retryable set in `RemoteSessionStore.handleFailure`, + // which maps exactly these reasons to `ConnectionPhase.RECONNECTING`. + switch reasonName { + case "NETWORK", "TIMEOUT", "TRANSPORT": return true + default: return false + } + } +} diff --git a/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift b/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift index 17407159b5..30ae5a26a4 100644 --- a/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift +++ b/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift @@ -184,14 +184,13 @@ final class MobileConversationRow: Identifiable, Equatable { let blocks: [MobileTimelineBlock] let streaming: Bool let typing: Bool - let pending: Bool let showRetry: Bool let error: String? let live: Bool init(id: String, kind: String, text: String, thinking: String?, images: [MobileTimelineImage], tools: [MobileTimelineTool], blocks: [MobileTimelineBlock], streaming: Bool, typing: Bool, - pending: Bool, showRetry: Bool, error: String?, live: Bool = false) { + showRetry: Bool, error: String?, live: Bool = false) { self.id = nativeTimelineString(id) self.kind = nativeTimelineString(kind) self.text = nativeTimelineString(text) @@ -201,7 +200,6 @@ final class MobileConversationRow: Identifiable, Equatable { self.blocks = blocks.map(nativeTimelineBlock) self.streaming = streaming self.typing = typing - self.pending = pending self.showRetry = showRetry self.error = error.map(nativeTimelineString) self.live = live @@ -213,7 +211,7 @@ final class MobileConversationRow: Identifiable, Equatable { if self === other { return true } return id == other.id && kind == other.kind && text == other.text && thinking == other.thinking && images == other.images && tools == other.tools && blocks == other.blocks && - streaming == other.streaming && typing == other.typing && pending == other.pending && + streaming == other.streaming && typing == other.typing && showRetry == other.showRetry && error == other.error && live == other.live } diff --git a/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings b/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings index 2bceb4c267..e6a01b355e 100644 --- a/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings +++ b/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings @@ -1637,16 +1637,6 @@ } } }, - "正在发送": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Sending" - } - } - } - }, "正在回复": { "localizations": { "en": { @@ -6877,16 +6867,6 @@ } } }, - "已发送,等待桌面端响应": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Sent. Waiting for the desktop." - } - } - } - }, "选择图片": { "localizations": { "en": { diff --git a/src/apps/mobile/ios/OpenBitFunUITests/RemoteCodeSessionSendUITests.swift b/src/apps/mobile/ios/OpenBitFunUITests/RemoteCodeSessionSendUITests.swift index 4273d34a46..22dc48be4a 100644 --- a/src/apps/mobile/ios/OpenBitFunUITests/RemoteCodeSessionSendUITests.swift +++ b/src/apps/mobile/ios/OpenBitFunUITests/RemoteCodeSessionSendUITests.swift @@ -1104,6 +1104,29 @@ final class StreamingPresentationUITests: XCTestCase { override func setUpWithError() throws { continueAfterFailure = false } + func testHistoryPageKeepsPositionAndDoesNotQueueMoreDrags() { + let app = XCUIApplication() + app.launchArguments = ["--streaming-regression", "--history-pagination-regression"] + app.launch() + XCTAssertTrue(app.buttons["fixture.shortHistory"].waitForExistence(timeout: 15)) + app.buttons["fixture.shortHistory"].tap() + let scroll = app.scrollViews.firstMatch + // Multiple gestures while the page is in flight must not queue work. + for _ in 0..<3 { scroll.swipeDown() } + let requests = app.staticTexts["fixture.historyRequests"] + XCTAssertEqual(requests.label, "History requests: 1") + let row = app.staticTexts["History row 0"] + XCTAssertTrue(row.exists) + let top = row.frame.minY + app.buttons["fixture.deliverHistory"].tap() + let settled = NSPredicate { _, _ in app.buttons["timeline.loadOlder"].isEnabled } + expectation(for: settled, evaluatedWith: nil) + waitForExpectations(timeout: 5) + XCTAssertEqual(row.frame.minY, top, accuracy: 4, "Prepending must preserve the existing visible row") + XCTAssertEqual(requests.label, "History requests: 1", "Layout must not drain another page") + let shot = XCTAttachment(screenshot: app.screenshot()); shot.lifetime = .keepAlways; add(shot) + } + func testPrependingHistoryPreservesVisibleRowOffset() { let app = XCUIApplication() app.launchArguments = ["--streaming-regression"] diff --git a/src/apps/mobile/ios/Testing/ComposerDismissGestureTests.swift b/src/apps/mobile/ios/Testing/ComposerDismissGestureTests.swift new file mode 100644 index 0000000000..fda17ab0df --- /dev/null +++ b/src/apps/mobile/ios/Testing/ComposerDismissGestureTests.swift @@ -0,0 +1,47 @@ +import Foundation + +@main +struct ComposerDismissGestureTests { + static func main() { + // An intentional downward drag on the focused input row collapses the keyboard. + expect(ComposerDismissGesture.dismissesKeyboard( + translation: CGSize(width: 0, height: 60), isFocused: true + ), "a straight downward drag dismisses the keyboard") + + // A drag that mostly travels sideways belongs to the field, not the dismissal. + expect(!ComposerDismissGesture.dismissesKeyboard( + translation: CGSize(width: 120, height: 40), isFocused: true + ), "a mostly horizontal drag leaves the keyboard alone") + + // Just past the axis bias is still a vertical intent. + expect(ComposerDismissGesture.dismissesKeyboard( + translation: CGSize(width: 20, height: 60), isFocused: true + ), "a vertical drag with modest sideways travel still dismisses") + + // Below the threshold nothing happens, in either direction. + for height in [-80.0, 0, 12, 35] { + expect(!ComposerDismissGesture.dismissesKeyboard( + translation: CGSize(width: 0, height: height), isFocused: true + ), "a \(height) vertical translation is not a dismissal") + } + expect(ComposerDismissGesture.dismissesKeyboard( + translation: CGSize(width: 0, height: 37), isFocused: true + ), "the threshold itself starts dismissing") + + // An unfocused field has no keyboard to dismiss, however far the drag goes. + expect(!ComposerDismissGesture.dismissesKeyboard( + translation: CGSize(width: 0, height: 200), isFocused: false + ), "an unfocused composer ignores the drag") + + // The drag must be long enough to exclude a tap before it is even tracked. + expect(ComposerDismissGesture.minimumDistance > 0 + && ComposerDismissGesture.minimumDistance < ComposerDismissGesture.threshold, + "the recognition distance stays below the dismissal threshold") + + print("Composer dismiss gesture tests passed") + } + + private static func expect(_ condition: @autoclosure () -> Bool, _ message: String) { + precondition(condition(), message) + } +} diff --git a/src/apps/mobile/ios/Testing/HistoryPageArrivalTrackerTests.swift b/src/apps/mobile/ios/Testing/HistoryPageArrivalTrackerTests.swift new file mode 100644 index 0000000000..4716478dbb --- /dev/null +++ b/src/apps/mobile/ios/Testing/HistoryPageArrivalTrackerTests.swift @@ -0,0 +1,21 @@ +import Foundation + +@main +struct HistoryPageArrivalTrackerTests { + static func main() { + var tracker = HistoryPageArrivalTracker() + precondition(!tracker.arrived(atStart: true), "Layout alone cannot request history") + tracker.beginGesture() + precondition(!tracker.arrived(atStart: false)) + precondition(tracker.arrived(atStart: true)) + for _ in 0..<10 { + precondition(!tracker.arrived(atStart: false)) + precondition(!tracker.arrived(atStart: true), "Bounce/layout cannot request a second page") + } + tracker.beginGesture() + precondition(tracker.arrived(atStart: true), "A new drag may request another page") + tracker.cancelArrival() + precondition(!tracker.arrived(atStart: true), "Refused/manual requests cannot queue a retry") + print("History page gesture gate tests passed") + } +} diff --git a/src/apps/mobile/ios/Testing/MobileProcessGroupTests.swift b/src/apps/mobile/ios/Testing/MobileProcessGroupTests.swift index 7f439359a0..d36e8f65f9 100644 --- a/src/apps/mobile/ios/Testing/MobileProcessGroupTests.swift +++ b/src/apps/mobile/ios/Testing/MobileProcessGroupTests.swift @@ -10,17 +10,17 @@ struct MobileProcessGroupTests { static func snapshotTests() { func row(_ id: String = "row", text: String = "body", blocks: [MobileTimelineBlock] = [], - tools: [MobileTimelineTool] = [], pending: Bool = false, live: Bool = false, + tools: [MobileTimelineTool] = [], live: Bool = false, error: String? = nil) -> MobileConversationRow { MobileConversationRow(id: id, kind: "ASSISTANT", text: text, thinking: nil, images: [], tools: tools, blocks: blocks, streaming: live, typing: false, - pending: pending, showRetry: error != nil, error: error, live: live) + showRetry: error != nil, error: error, live: live) } let original = row() let same = row() precondition(original != same, "View equality must compare identity without scanning content") precondition(MobileConversationRow.reconcile([same], with: [original])[0] === original) - for changed in [row(text: "edited"), row(pending: true), row(live: true), row(error: "failed"), + for changed in [row(text: "edited"), row(live: true), row(error: "failed"), row(blocks: [.thinking(id: "thought", text: "Updated", streaming: true)]), row(tools: [tool("new")])] { precondition(MobileConversationRow.reconcile([changed], with: [original])[0] === changed, diff --git a/src/apps/mobile/ios/Testing/RemoteAuthorityGateTests.swift b/src/apps/mobile/ios/Testing/RemoteAuthorityGateTests.swift index 64b1811f3c..8f27ba5c0c 100644 --- a/src/apps/mobile/ios/Testing/RemoteAuthorityGateTests.swift +++ b/src/apps/mobile/ios/Testing/RemoteAuthorityGateTests.swift @@ -614,6 +614,27 @@ struct RemoteAuthorityGateTests { draftIsEmpty: emptyDraft, attachmentsAreEmpty: emptyImages ), "send settlement preserves newer typing, attachments and another session") } + for reason in ["NETWORK", "TIMEOUT", "TRANSPORT"] { + expect(RemoteSessionFailureProjectionPolicy.keepsVisibleConversation(reasonName: reason), + "a retryable transport failure keeps the rendered conversation") + } + // The store maps exactly these reasons to `ConnectionPhase.RECONNECTING`; + // everything else is a deterministic end the projection must follow. + for reason in [ + "SESSION_NOT_FOUND", "PROTOCOL_MISMATCH", "NO_WORKSPACE", "REMOTE_REJECTED", + "RATE_LIMITED", "WORKSPACE_ID_UNSUPPORTED", "WORKSPACE_ID_UNKNOWN", + "HOST_STREAM_UNSUPPORTED", + ] { + expect(!RemoteSessionFailureProjectionPolicy.keepsVisibleConversation(reasonName: reason), + "a deterministic \(reason) failure ends the projection") + } + expectCallBeforeMutation( + in: remoteSessionSource, + function: "func apply(remoteState state: RemoteSessionUiState", + call: "RemoteSessionFailureProjectionPolicy.keepsVisibleConversation(", + mutation: "timelineRows = []", + message: "a retryable remote failure is classified before any projection is cleared" + ) expectCallBeforeMutation( in: remoteSessionSource, function: "func sendRemote()", diff --git a/src/apps/mobile/ios/Testing/run-pure-swift-tests.sh b/src/apps/mobile/ios/Testing/run-pure-swift-tests.sh index 6c76c729b4..c7bd14ac5e 100755 --- a/src/apps/mobile/ios/Testing/run-pure-swift-tests.sh +++ b/src/apps/mobile/ios/Testing/run-pure-swift-tests.sh @@ -31,6 +31,12 @@ xcrun --sdk macosx swiftc \ -o "$OUTPUT_DIR/streaming-text-state-tests" "$OUTPUT_DIR/streaming-text-state-tests" +xcrun --sdk macosx swiftc \ + "$IOS_DIR/OpenBitFun/Infrastructure/ComposerDismissGesture.swift" \ + "$SCRIPT_DIR/ComposerDismissGestureTests.swift" \ + -o "$OUTPUT_DIR/composer-dismiss-gesture-tests" +"$OUTPUT_DIR/composer-dismiss-gesture-tests" + xcrun --sdk macosx swiftc \ "$IOS_DIR/OpenBitFun/Presentation/Models/MobilePresentationModels.swift" \ "$SCRIPT_DIR/MobileProcessGroupTests.swift" \ @@ -44,3 +50,9 @@ xcrun --sdk macosx swiftc \ "$SCRIPT_DIR/StartupRevealPreferenceTests.swift" \ -o "$OUTPUT_DIR/startup-reveal-preference-tests" "$OUTPUT_DIR/startup-reveal-preference-tests" + +xcrun --sdk macosx swiftc \ + "$IOS_DIR/OpenBitFun/Features/Chat/HistoryPageArrivalTracker.swift" \ + "$SCRIPT_DIR/HistoryPageArrivalTrackerTests.swift" \ + -o "$OUTPUT_DIR/history-page-arrival-tracker-tests" +"$OUTPUT_DIR/history-page-arrival-tracker-tests" diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ConversationPresentation.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ConversationPresentation.kt index b0e653b1de..bb5b1c9075 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ConversationPresentation.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ConversationPresentation.kt @@ -159,8 +159,6 @@ public data class ConversationRow public constructor( public val streaming: Boolean, /** Streaming, with nothing to show yet; apps draw the waiting indicator. */ public val typing: Boolean, - /** Sent from this device but not yet echoed back by the desktop. */ - public val pending: Boolean, /** The send failed and this is the row a retry would repeat. */ public val showRetry: Boolean, /** A user-visible assistant failure returned by the desktop. */ @@ -210,7 +208,6 @@ public fun ChatTimelineState.conversationRows(): List = live = item.type == ChatTimelineItemType.ASSISTANT_LIVE_TURN, streaming = item.isStreaming, typing = message?.let { isTyping(it, item.isStreaming) } == true, - pending = item.type == ChatTimelineItemType.OPTIMISTIC_USER_MESSAGE, showRetry = item.showRetryAction, error = message?.error?.trim()?.takeIf(String::isNotEmpty), ) diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentation.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentation.kt index 2912547719..0c9d6d2085 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentation.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentation.kt @@ -101,10 +101,15 @@ private fun scopeSubagentItems(items: List): List() val marked = items.any { it.isSubagent == true } var taskIndex: Int? = null + // A Task's children can arrive nested in its own items and again flat behind + // it, so folding the restatement would draw every child twice. A counted + // restatement is skipped, and a child that legitimately repeats survives. + var carried = mutableListOf() for (entry in items) { if (entry.isSubagent != true && entry.tool?.let(ToolNamePolicy::isTask) == true) { result += entry taskIndex = result.lastIndex + carried = result.last().subItems.orEmpty().indices.toMutableList() continue } val owner = taskIndex?.let(result::get) @@ -113,7 +118,27 @@ private fun scopeSubagentItems(items: List): List= 0) { + // A flat tool record can be the newer restatement of the nested + // copy. Keep its result/status while consuming only this one + // nested occurrence; a second identical flat child remains real. + val index = carried.removeAt(match) + val incomingTool = child.tool + if (incomingTool != null) { + val updated = children.toMutableList() + updated[index] = child.copy( + content = child.content ?: children[index].content, + subItems = child.subItems ?: children[index].subItems, + tool = mergeChildTool(children[index].tool, incomingTool), + ) + result[taskIndex] = owner.copy(subItems = updated) + } + } else { + result[taskIndex] = owner.copy(subItems = owner.subItems.orEmpty() + child) + } } else { // A partial snapshot may omit the owning Task. Retain a collapsed // branch with its content instead of exposing it as parent output. @@ -129,12 +154,21 @@ private fun scopeSubagentItems(items: List): List, path: String, streaming: Boolean): List { val blocks = mutableListOf() val toolRun = mutableListOf() - var toolStart = 0 val lastIndex = items.indexOfLast(::isRenderable) + // An id is view identity: changing it discards the drawn block together with + // its expansion and scroll state. Counting each kind separately keeps an id + // while siblings of other kinds arrive, so a turn cannot renumber itself. + val ordinals = mutableMapOf() + fun nextId(kind: String): String { + val ordinal = ordinals[kind] ?: 0 + ordinals[kind] = ordinal + 1 + return "$path-$kind-$ordinal" + } + fun flushTools() { if (toolRun.isEmpty()) return - blocks += MessageBlock.Tools("$path-tools-$toolStart", toolRun.map(::toolCard)) + blocks += MessageBlock.Tools(nextId("tools"), toolRun.map(::toolCard)) toolRun.clear() } @@ -147,38 +181,38 @@ private fun walk(items: List, path: String, streaming: val status = entry.tool?.status?.takeIf(String::isNotBlank)?.lowercase() ?: if (live && !entry.subItems.isNullOrEmpty()) "running" else "completed" val running = status in SUBAGENT_RUNNING + val id = nextId("subagent") blocks += MessageBlock.Subagent( - id = "$path-$index-subagent", + id = id, title = subagentTitle(entry), running = running, text = subagentBody(entry), - children = walk(entry.subItems.orEmpty().map { it.copy(isSubagent = false) }, "$path-$index", running), + children = walk(entry.subItems.orEmpty().map { it.copy(isSubagent = false) }, id, running), status = status, ) return@forEachIndexed } val tool = entry.tool if (tool != null) { - if (toolRun.isEmpty()) toolStart = index toolRun += tool return@forEachIndexed } flushTools() when { isThinking(entry) -> blocks += MessageBlock.Thinking( - id = "$path-$index-thinking", + id = nextId("thinking"), text = entry.content.orEmpty().trim(), streaming = live, ) isText(entry) -> blocks += MessageBlock.Text( - id = "$path-$index-text", + id = nextId("text"), text = entry.content.orEmpty().trim(), streaming = live, ) } entry.subItems?.takeIf(List::isNotEmpty)?.let { children -> - blocks += walk(children, "$path-$index", streaming && live) + blocks += walk(children, nextId("nested"), streaming && live) } } flushTools() @@ -219,6 +253,23 @@ private fun fingerprint(tool: RemoteToolStatusResponse): String { ).joinToString("|") } +/** + * What makes two subagent children the same piece of work. + * + * Children arrive without an id, so the kind, its content, and the tool it names + * are all there is to tell one from the other. + */ +private fun childFingerprint(entry: ChatMessageItemResponse): String = listOf( + entry.type.orEmpty().lowercase(), + entry.content.orEmpty(), + entry.tool?.let(::fingerprint).orEmpty(), +).joinToString("\u0000") + +private fun sameChildIdentity(a: ChatMessageItemResponse, b: ChatMessageItemResponse): Boolean = + if (a.tool?.id.orEmpty().isNotEmpty() || b.tool?.id.orEmpty().isNotEmpty()) { + a.tool?.id.orEmpty() == b.tool?.id.orEmpty() && a.tool?.id.orEmpty().isNotEmpty() + } else childFingerprint(a) == childFingerprint(b) + private fun isRenderable(entry: ChatMessageItemResponse): Boolean = isThinking(entry) || isText(entry) || isSubagent(entry) || entry.tool != null || entry.subItems.orEmpty().any(::isRenderable) @@ -276,3 +327,22 @@ private val SUBAGENT_RUNNING = setOf("running", "active", "preparing", "pending" private const val TITLE_LIMIT = 80 private val TEXT_TYPES = setOf("text", "message", "") private val SUBAGENT_TYPES = setOf("subagent", "agent") + +/** Legacy completion records can omit the invocation fields already in the nested copy. */ +private fun mergeChildTool(previous: RemoteToolStatusResponse?, incoming: RemoteToolStatusResponse): RemoteToolStatusResponse = + incoming.copy( + id = incoming.id ?: previous?.id, + name = incoming.name ?: previous?.name, + status = incoming.status ?: previous?.status, + durationMs = incoming.durationMs ?: previous?.durationMs, + startMs = incoming.startMs ?: previous?.startMs, + inputPreview = incoming.inputPreview ?: previous?.inputPreview, + toolInput = incoming.toolInput ?: previous?.toolInput, + stdout = incoming.stdout ?: previous?.stdout, + stderr = incoming.stderr ?: previous?.stderr, + toolOutput = incoming.toolOutput ?: previous?.toolOutput, + resultPreview = incoming.resultPreview ?: previous?.resultPreview, + errorPreview = incoming.errorPreview ?: previous?.errorPreview, + exitCode = incoming.exitCode ?: previous?.exitCode, + plan = incoming.plan ?: previous?.plan, + ) diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt index 097cc8997e..6ca65a108e 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt @@ -51,6 +51,7 @@ import com.openbitfun.mobile.core.transport.HostStreamUnsupportedException import com.openbitfun.mobile.core.transport.REMOTE_CAPABILITY_HOST_STREAM_V1 import com.openbitfun.mobile.core.transport.RemoteSessionStreamTransport import com.openbitfun.mobile.core.transport.STREAM_EVENT_GAP +import com.openbitfun.mobile.core.transport.STREAM_EVENT_HISTORY_STARTED import com.openbitfun.mobile.core.transport.STREAM_EVENT_READY import com.openbitfun.mobile.core.transport.STREAM_EVENT_RESUMED import kotlinx.coroutines.flow.collect @@ -628,6 +629,7 @@ public class RemoteSessionStore internal constructor( // The next connection may reach a different build; ask it again. hostCapabilitiesKnown = false transcriptWrite?.cancel() + forgetWrittenTranscript() _connectionPhase.value = ConnectionPhase.DISCONNECTED } @@ -991,6 +993,17 @@ public class RemoteSessionStore internal constructor( private var sessionHistoryHasMore = false private var transcriptWrite: Job? = null + /** + * True while the transcript changed without a write behind it. + * + * A history page is read oldest-first, so every record of the burst prepends + * to the window and no already written row can be reused: writing during the + * burst rewrites the whole transcript per record, on the thread that draws + * the screen, for a page nobody has finished reading yet. The page flushes + * once when it settles. + */ + private var transcriptDirty = false + /** * Holds the transcript write to one per [TRANSCRIPT_WRITE_DEBOUNCE_MS] while a turn streams. * @@ -1000,19 +1013,31 @@ public class RemoteSessionStore internal constructor( * reopened, so only the last write of a burst ever mattered. */ private fun scheduleTranscriptWrite(sessionId: String) { + transcriptDirty = true if (transcriptWrite?.isActive == true) return transcriptWrite = scope.launch { delay(TRANSCRIPT_WRITE_DEBOUNCE_MS) persistTranscript(sessionId, preserveOlder = sessionHistoryHasMore) + transcriptDirty = false } } + /** Defers a page-in-flight write until the page settles; see [transcriptDirty]. */ + private fun deferTranscriptWrite() { + transcriptDirty = true + } + private fun writeTranscriptNow(sessionId: String) { transcriptWrite?.cancel() transcriptWrite = null persistTranscript(sessionId, preserveOlder = sessionHistoryHasMore) + transcriptDirty = false } + /** True while a history page is being read, so its records arrive as one burst. */ + private fun historyLoading(): Boolean = + (_state.value as? RemoteSessionUiState.Ready)?.historyLoadState == HistoryLoadState.LOADING + private fun publishDurableTimeline() { val current = _state.value as? RemoteSessionUiState.Ready ?: return val snapshot = timelineStore.snapshot() @@ -1026,6 +1051,7 @@ public class RemoteSessionStore internal constructor( permissionMailbox.select(sessionId) sessionUpdates?.cancel() transcriptWrite?.cancel() + forgetWrittenTranscript() sessionHistoryHasMore = false sessionUpdates = scope.launch { try { @@ -1036,6 +1062,7 @@ public class RemoteSessionStore internal constructor( if (hostCapabilitiesKnown && REMOTE_CAPABILITY_HOST_STREAM_V1 !in hostCapabilities) throw HostStreamUnsupportedException() var records = SessionRecordReplica(sessionId) var caughtUp = false + var replayingHistory = false /** * Renders everything received so far and reports the turn's phase. * @@ -1079,13 +1106,16 @@ public class RemoteSessionStore internal constructor( val kind = (payload["toolEvent"] as? JsonObject)?.get("event_type")?.jsonPrimitive?.content if (kind in setOf("ConfirmationNeeded", "Confirmed", "Rejected", "Cancelled")) permissionMailbox.invalidate() } - if (caughtUp) { + if (caughtUp && replayingHistory) deferTranscriptWrite() + if (caughtUp && !replayingHistory) { val phase = render() publishDurableTimeline() // A streaming turn rewrites the same rows on every - // chunk; anything else is a settled transcript worth - // keeping now. - if (phase == ChatSyncPhase.STREAMING) scheduleTranscriptWrite(sessionId) + // chunk, and a history page arrives as dozens of + // records in one burst. Neither is worth a write per + // record; the page is flushed when it settles. + if (historyLoading()) deferTranscriptWrite() + else if (phase == ChatSyncPhase.STREAMING) scheduleTranscriptWrite(sessionId) else writeTranscriptNow(sessionId) } } @@ -1093,14 +1123,22 @@ public class RemoteSessionStore internal constructor( // The host restarted this stream: everything derived from // the previous replay is stale and the latest page follows. STREAM_EVENT_GAP -> { + replayingHistory = true records = SessionRecordReplica(sessionId) timelineStore.reset(sessionId) permissionMailbox.invalidate() } + STREAM_EVENT_HISTORY_STARTED -> replayingHistory = true STREAM_EVENT_READY -> { + replayingHistory = false sessionHistoryHasMore = payload["hasMore"]?.jsonPrimitive?.content == "true" val current = _state.value as? RemoteSessionUiState.Ready - if (current != null) _state.value = current.copy(hasMoreMessages = sessionHistoryHasMore) + if (caughtUp) { + render() + publishDurableTimeline() + } else if (current != null) { + _state.value = current.copy(hasMoreMessages = sessionHistoryHasMore) + } } "session-state" -> { val status = payload["status"]?.jsonPrimitive?.content @@ -1110,7 +1148,7 @@ public class RemoteSessionStore internal constructor( "failed", "error" -> ChatSyncPhase.ERROR else -> ChatSyncPhase.IDLE }) - if (caughtUp) publishDurableTimeline() + if (caughtUp && !replayingHistory) publishDurableTimeline() } } } @@ -1180,6 +1218,9 @@ public class RemoteSessionStore internal constructor( _state.value = it.copy(historyLoadState = HistoryLoadState.IDLE) } } + // The page's records were written at most once; this is where + // the settled transcript lands. + if (transcriptDirty) writeTranscriptNow(sessionId) } } } @@ -1438,6 +1479,7 @@ public class RemoteSessionStore internal constructor( RemoteCommand(cmd = "delete_session", sessionId = normalized), ) locallyCreatedSessions.remove(normalized) + forgetWrittenTranscript(normalized) if (persistenceEnabled) { persistedSessionSlice()?.let { persisted -> val persistedSessions = persisted.sessions @@ -1598,7 +1640,12 @@ public class RemoteSessionStore internal constructor( ) if (!isCurrentWork(operationToken)) return@launch response.turnId?.takeIf(String::isNotBlank)?.let { turnId -> - timelineStore.acknowledgeOptimisticTurn(local.id, turnId) + // A running-input acknowledgement names the existing execution, + // not this user message. Its initial user bubble must not consume + // the newly submitted bubble through turn-based deduplication. + if (turnId != activeTurnId) { + timelineStore.acknowledgeOptimisticTurn(local.id, turnId) + } if (!steering && current.timeline?.activeTurn == null) timelineStore.setLocalActiveTurn(turnId) } ?: timelineStore.clearPendingActiveTurn(pendingActiveId) (transport as? RemoteSessionStreamTransport)?.wakeSessionStreams() @@ -1909,6 +1956,31 @@ public class RemoteSessionStore internal constructor( RemotePermissionMode.Unknown -> SessionPermissionMode.UNKNOWN } + /** + * Rows this store last wrote for a session, kept so the next write can reuse them. + * + * A record restates the whole transcript and a write re-encodes it, deletes the + * table and inserts it again — megabytes of work on the thread that draws the + * screen, repeated for every record that arrives. Most of a transcript does not + * change between two writes, so keeping the last written rows lets a write touch + * only the messages that changed. This store is the only writer of that table. + */ + private class WrittenTranscript( + /** The window as written, in order, parallel to [windowRows]. */ + val messages: List, + /** The persisted form of each window message. */ + val windowRows: List, + /** Cached rows in front of the window that the loaded records do not cover. */ + val older: List, + ) + + private var writtenTranscript: Pair? = null + + private fun forgetWrittenTranscript(sessionId: String? = null) { + val current = writtenTranscript ?: return + if (sessionId == null || current.first.endsWith("::$sessionId")) writtenTranscript = null + } + private fun persistTranscript(sessionId: String, preserveOlder: Boolean = true) { if (!persistenceEnabled || sessionId.isEmpty()) return val snapshot = timelineStore.snapshot() @@ -1916,16 +1988,38 @@ public class RemoteSessionStore internal constructor( try { val p = persistence!! val persistedDeviceKey = deviceKey!! - val window = snapshot.persistedMessages.map { toPersisted(sessionId, it) } - val windowIds = window.mapTo(mutableSetOf()) { it.messageId } + val key = "$persistedDeviceKey::$sessionId" + val previous = writtenTranscript?.takeIf { it.first == key }?.second + val messages = snapshot.persistedMessages + val windowIds = messages.mapTo(mutableSetOf()) { it.id } // A paginated re-read (limit 100) must not truncate pages the user already // loaded: keep older cached rows the current window does not cover. val older = if (preserveOlder) { - p.remoteTranscripts.load(persistedDeviceKey, sessionId).filterNot { it.messageId in windowIds } + previous?.older?.filterNot { it.messageId in windowIds } + ?: p.remoteTranscripts.load(persistedDeviceKey, sessionId).filterNot { it.messageId in windowIds } } else { emptyList() } - p.remoteTranscripts.replace(persistedDeviceKey, sessionId, older + window) + val windowRows = messages.mapIndexed { index, message -> + // Encoding is the expensive half of a write, and an unchanged message + // is still the instance the replica handed out last time. + val known = previous?.messages + if (known != null && index < known.size && known[index] === message) previous.windowRows[index] + else toPersisted(sessionId, message) + } + val rows = older + windowRows + val writtenRows = previous?.let { it.older + it.windowRows } + // Reused rows are the very instances written last time, so comparing by + // identity separates "this transcript did not change" and "only its tail + // did" from a rewrite, without reading anything back. + val shared = if (writtenRows == null) 0 else rows.indices.takeWhile { writtenRows.size > it && rows[it] === writtenRows[it] }.size + if (writtenRows == null || shared != rows.size || writtenRows.size != rows.size) { + // A strict prefix still has stale rows behind it, and a changed head + // (a page strictly prepends) cannot be appended to. + if (shared >= 1 && shared < rows.size) p.remoteTranscripts.append(persistedDeviceKey, sessionId, shared, rows.drop(shared)) + else p.remoteTranscripts.replace(persistedDeviceKey, sessionId, rows) + } + writtenTranscript = key to WrittenTranscript(messages, windowRows, older) p.remoteTranscripts.saveCursor(persistedDeviceKey, sessionId, PersistedRemoteCursor( pollVersion = snapshot.cursor.pollVersion.toString(), knownMessageCount = snapshot.cursor.knownMessageCount, diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/SessionRecordReplica.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/SessionRecordReplica.kt index e3be672b41..35b1a5c3b4 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/SessionRecordReplica.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/SessionRecordReplica.kt @@ -25,13 +25,46 @@ internal class SessionRecordReplica(private val sessionId: String) { val id = event.string("tool_id") if (turn.isEmpty() || id.isEmpty()) return when (event.string("event_type")) { - "ConfirmationNeeded" -> controls[id] = turn to RemoteToolStatusResponse(id = id, name = event.string("tool_name"), status = "pending_confirmation", toolInput = event["params"]) - "Confirmed", "Completed", "Failed", "Cancelled", "Rejected" -> controls.remove(id) + "ConfirmationNeeded" -> { + controls[id] = turn to RemoteToolStatusResponse(id = id, name = event.string("tool_name"), status = "pending_confirmation", toolInput = event["params"]) + touchTurn(turn) + } + "Confirmed", "Completed", "Failed", "Cancelled", "Rejected" -> { + val owner = controls.remove(id)?.first + if (owner != null) touchTurn(owner) + } } } private val recordVersions = mutableMapOf() private val tombstones = mutableMapOf() private val itemRounds = mutableMapOf() + + /** + * Messages already rendered for a turn, kept until that turn changes again. + * + * Every record restates the whole session and the timeline reads all of it, so + * rendering used to re-parse every message of every turn — the entire loaded + * transcript, contents and all — for each record that arrived, on the thread + * that draws the screen. A turn whose records and controls have not changed + * renders to the same pair of messages, and handing back the same instances + * also lets everything downstream compare them by identity instead of by + * content. + * + * [turnGenerations] is the whole correctness argument: it is bumped by every + * mutation that can change what a turn renders to, so a matching generation + * means the cached pair is still the current one. + */ + private data class RenderedTurn(val generation: Long?, val messages: List) + + private val renderedTurns = mutableMapOf() + private val turnGenerations = mutableMapOf() + private var turnGenerationSeq = 0L + + /** Records the next render of [turnId] as different from the cached one. */ + private fun touchTurn(turnId: String) { + turnGenerations[turnId] = ++turnGenerationSeq + } + private fun put(map: MutableMap, id: String, revision: Long, value: JsonObject) { if (revision > (map[id]?.revision ?: -1)) map[id] = Versioned(revision, value) } @@ -54,6 +87,12 @@ internal class SessionRecordReplica(private val sessionId: String) { if (payload["deleted"]?.jsonPrimitive?.booleanOrNull == true) { recordVersions[recordId] = revision if (revision > (tombstones[recordId] ?: -1)) tombstones[recordId] = revision + // A deletion retires a turn, one of its rounds, or one of its items. + // Only a whole turn is named by its own record id, so anything else + // falls back to rendering every turn again. + val named = recordId.removePrefix("turn/").takeIf { recordId.startsWith("turn/") } + val owner = named ?: (payload["turn"] as? JsonObject)?.string("turnId") + if (owner.isNullOrEmpty()) renderedTurns.clear() else touchTurn(owner) return } if (revision <= (tombstones[recordId] ?: -1)) return @@ -87,6 +126,7 @@ internal class SessionRecordReplica(private val sessionId: String) { put(items, itemId, revision, recordItem) } } + touchTurn(turnId) } /** @@ -117,43 +157,68 @@ internal class SessionRecordReplica(private val sessionId: String) { fun messages(): List { val index = children() - return turns.filter { (id, record) -> record.revision > (tombstones["turn/$id"] ?: -1) }.values.sortedBy { it.value.number("turnIndex") }.flatMap { record -> - val turn = record.value - val turnId = turn.string("turnId") - val user = turn.getValue("userMessage").jsonObject - val turnFence = tombstones["turn/$turnId"] ?: -1 - val children = index.roundsByTurn[turnId].orEmpty().filter { (id, record) -> record.revision > maxOf(turnFence, tombstones["round/$id"] ?: -1) } - .sortedBy { it.second.value.number("roundIndex") }.flatMap { (currentRoundId, _) -> - val roundFence = maxOf(turnFence, tombstones["round/$currentRoundId"] ?: -1) - index.itemsByRound[currentRoundId].orEmpty().filter { (id, record) -> record.revision > maxOf(roundFence, tombstones["item/$id"] ?: -1) }.map { it.second.value } - .filter { it.getValue("data").jsonObject.string("status") !in setOf("superseded", "retry_superseded") } - .sortedWith(compareBy({ it.getValue("data").jsonObject.number("orderIndex") }, { it.getValue("data").jsonObject.number("timestamp") })) - } - val rendered = children.map { item -> - val data = item.getValue("data").jsonObject - val type = item.string("type") - val result = data["toolResult"] as? JsonObject - ChatMessageItemResponse(type = type, content = data.string("content"), isSubagent = data["isSubagentItem"]?.jsonPrimitive?.booleanOrNull == true || data.string("subagentSessionId").isNotEmpty(), - tool = if (type != "tool") null else RemoteToolStatusResponse( - id = (data["toolCall"] as? JsonObject)?.string("id")?.takeIf { it.isNotEmpty() } ?: data.string("id"), name = data.string("toolName"), - status = data.string("status").takeIf { it.isNotEmpty() } - ?: if (result == null) "running" else if (result["success"]?.jsonPrimitive?.booleanOrNull == true) "completed" else "failed", - toolInput = (data["toolCall"] as? JsonObject)?.get("input"), toolOutput = result?.get("result"), - errorPreview = result?.string("error"), - startMs = data["startTime"]?.jsonPrimitive?.longOrNull, - durationMs = data["durationMs"]?.jsonPrimitive?.longOrNull - ?: result?.get("durationMs")?.jsonPrimitive?.longOrNull)) + val visible = turns.filter { (id, record) -> record.revision > (tombstones["turn/$id"] ?: -1) } + .values.sortedBy { it.value.number("turnIndex") } + val messages = ArrayList(visible.size * 2) + visible.forEach { record -> + val turnId = record.value.string("turnId") + val cached = renderedTurns[turnId] + val generation = turnGenerations[turnId] + if (cached != null && cached.generation == generation) { + messages += cached.messages + } else { + val rendered = renderTurn(record.value, turnId, index) + // renderTurn may retire stale control entries and advance the + // generation; cache against the post-render value. + renderedTurns[turnId] = RenderedTurn(turnGenerations[turnId], rendered) + messages += rendered + } + } + if (renderedTurns.size > visible.size) renderedTurns.keys.retainAll(visible.mapTo(mutableSetOf()) { it.value.string("turnId") }) + return messages + } + + /** Renders one turn, the expensive half of [messages]. */ + private fun renderTurn(turn: JsonObject, turnId: String, index: Children): List { + val user = turn.getValue("userMessage").jsonObject + val turnFence = tombstones["turn/$turnId"] ?: -1 + val children = index.roundsByTurn[turnId].orEmpty().filter { (id, record) -> record.revision > maxOf(turnFence, tombstones["round/$id"] ?: -1) } + .sortedBy { it.second.value.number("roundIndex") }.flatMap { (currentRoundId, _) -> + val roundFence = maxOf(turnFence, tombstones["round/$currentRoundId"] ?: -1) + index.itemsByRound[currentRoundId].orEmpty().filter { (id, record) -> record.revision > maxOf(roundFence, tombstones["item/$id"] ?: -1) }.map { it.second.value } + .filter { it.getValue("data").jsonObject.string("status") !in setOf("superseded", "retry_superseded") } + .sortedWith(compareBy({ it.getValue("data").jsonObject.number("orderIndex") }, { it.getValue("data").jsonObject.number("timestamp") })) } - rendered.mapNotNull { it.tool }.filter { it.status in setOf("completed", "failed", "cancelled", "rejected", "skipped") }.forEach { controls.remove(it.id) } - val controlTools = controls.values.filter { it.first == turnId }.map { it.second } - val shownItems = rendered.map { item -> item.tool?.id?.let { id -> controlTools.firstOrNull { it.id == id } }?.let { item.copy(tool = it) } ?: item } + - controlTools.filter { tool -> rendered.none { it.tool?.id == tool.id } }.map { ChatMessageItemResponse(type = "tool", tool = it) } - listOf(RemoteResponseMapper.chatMessage(ChatMessageResponse(id = user.string("id"), role = "user", content = user.string("content"), turnId = turnId, metadata = user["metadata"], images = userImages(user), timestamp = user.string("timestamp"))), - RemoteResponseMapper.chatMessage(ChatMessageResponse(id = "${turnId}_assistant", role = "assistant", turnId = turnId, - content = rendered.filter { it.type == "text" && it.isSubagent != true }.joinToString("") { it.content.orEmpty() }, - thinking = rendered.filter { it.type == "thinking" && it.isSubagent != true }.joinToString("") { it.content.orEmpty() }, - items = shownItems, status = when (turn.string("status")) { "inprogress" -> "streaming"; "error" -> "failed"; else -> turn.string("status") }, error = turn.string("error"), metadata = turn))) + val rendered = children.map { item -> + val data = item.getValue("data").jsonObject + val type = item.string("type") + val result = data["toolResult"] as? JsonObject + ChatMessageItemResponse(type = type, content = data.string("content"), isSubagent = data["isSubagentItem"]?.jsonPrimitive?.booleanOrNull == true || data.string("subagentSessionId").isNotEmpty(), + tool = if (type != "tool") null else RemoteToolStatusResponse( + id = (data["toolCall"] as? JsonObject)?.string("id")?.takeIf { it.isNotEmpty() } ?: data.string("id"), name = data.string("toolName"), + status = data.string("status").takeIf { it.isNotEmpty() } + ?: if (result == null) "running" else if (result["success"]?.jsonPrimitive?.booleanOrNull == true) "completed" else "failed", + toolInput = (data["toolCall"] as? JsonObject)?.get("input"), toolOutput = result?.get("result"), + errorPreview = result?.string("error"), + startMs = data["startTime"]?.jsonPrimitive?.longOrNull, + durationMs = data["durationMs"]?.jsonPrimitive?.longOrNull + ?: result?.get("durationMs")?.jsonPrimitive?.longOrNull)) + } + val superseded = rendered.mapNotNull { it.tool }.filter { it.status in setOf("completed", "failed", "cancelled", "rejected", "skipped") } + if (superseded.isNotEmpty()) { + var controlsChanged = false + superseded.forEach { if (controls.remove(it.id) != null) controlsChanged = true } + // The cached render of a turn is only valid until its controls change. + if (controlsChanged) touchTurn(turnId) } + val controlTools = controls.values.filter { it.first == turnId }.map { it.second } + val shownItems = rendered.map { item -> item.tool?.id?.let { id -> controlTools.firstOrNull { it.id == id } }?.let { item.copy(tool = it) } ?: item } + + controlTools.filter { tool -> rendered.none { it.tool?.id == tool.id } }.map { ChatMessageItemResponse(type = "tool", tool = it) } + return listOf(RemoteResponseMapper.chatMessage(ChatMessageResponse(id = user.string("id"), role = "user", content = user.string("content"), turnId = turnId, metadata = user["metadata"], images = userImages(user), timestamp = user.string("timestamp"))), + RemoteResponseMapper.chatMessage(ChatMessageResponse(id = "${turnId}_assistant", role = "assistant", turnId = turnId, + content = rendered.filter { it.type == "text" && it.isSubagent != true }.joinToString("") { it.content.orEmpty() }, + thinking = rendered.filter { it.type == "thinking" && it.isSubagent != true }.joinToString("") { it.content.orEmpty() }, + items = shownItems, status = when (turn.string("status")) { "inprogress" -> "streaming"; "error" -> "failed"; else -> turn.string("status") }, error = turn.string("error"), metadata = turn))) } /** Attachments are recorded with the turn; one that kept only a host path has no diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/ConversationPresentationTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/ConversationPresentationTest.kt index e1ea330cda..a0a84238ad 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/ConversationPresentationTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/ConversationPresentationTest.kt @@ -39,11 +39,14 @@ class ConversationPresentationTest { } @Test - fun aMessageStillInFlightIsShownAsPendingUntilItsTwinArrives() { + fun aMessageStillInFlightIsShownOnceUntilItsTwinArrives() { val optimistic = timeline(optimistic = listOf(message("local-1", "user", "ship it"))) - assertEquals(listOf(true), optimistic.conversationRows().map { it.pending }) + assertEquals( + listOf("pending-local-1"), + optimistic.conversationRows().map { it.id }, + ) - // The same message identity persisted: one row, no longer pending. Reading + // The same message identity persisted: one row, not two. Reading // persistedMessages directly would have shown it twice. val persisted = timeline( persisted = listOf(message("local-1", "user", "ship it")), @@ -51,7 +54,7 @@ class ConversationPresentationTest { ) val rows = persisted.conversationRows() assertEquals(1, rows.size) - assertEquals(false, rows.single().pending) + assertEquals("message-local-1", rows.single().id) } @Test diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentationTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentationTest.kt index 6d957658b7..a6fa93ef1e 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentationTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentationTest.kt @@ -269,6 +269,105 @@ class MessageBlockPresentationTest { assertEquals(1, blocks.filterIsInstance().flatMap { it.tools }.size) } + @Test + fun aRunningTaskDoesNotDrawItsChildrenTwiceWhenTheyAlsoArriveFlat() { + val task = item( + tool = tool("task", name = "Task", status = "running"), + subItems = listOf( + item(type = "thinking", content = "child reasoning"), + item(tool = tool("read", status = "completed")), + ), + ) + val flatCopies = listOf( + item(type = "thinking", content = "child reasoning"), + item(tool = tool("read", status = "completed")), + ) + + val subagent = messageBlocks(message(items = listOf(task) + flatCopies), true).single() as MessageBlock.Subagent + + assertEquals(2, subagent.children.size) + } + + @Test + fun aChildThatRepeatsInsideTheTaskIsNotTreatedAsARestatement() { + val task = item( + tool = tool("task", name = "Task", status = "running"), + subItems = listOf(item(type = "thinking", content = "checking")), + ) + val flatTail = listOf( + item(type = "thinking", content = "checking"), + item(type = "thinking", content = "checking"), + ) + + val subagent = messageBlocks(message(items = listOf(task) + flatTail), true).single() as MessageBlock.Subagent + + assertEquals(2, subagent.children.size) + } + + @Test + fun repeatedFlatChildrenOnlyConsumeOriginalNestedOccurrences() { + for (marked in listOf(true, false)) { + for (nestedCount in listOf(0, 1, 2)) { + val child = item(type = "text", content = "Checking again").copy(isSubagent = marked) + val task = item(tool = tool("task", name = "Task", status = "running"), + subItems = List(nestedCount) { child }) + val shown = messageBlocks(message(items = listOf(task) + List(5) { child }), true) + .single() as MessageBlock.Subagent + assertEquals(5, shown.children.size, "Only $nestedCount nested copies may be matched") + assertEquals(nestedCount, task.subItems!!.size, "Projection must not mutate the source") + } + } + } + + @Test + fun flatToolRestatementUpdatesStatusOutputAndContentWithoutLosingNestedDetails() { + for (marked in listOf(true, false)) { + val nested = item(type = "tool", content = "old", tool = tool("read", status = "running", inputPreview = "original input"), + subItems = listOf(item(type = "text", content = "nested detail"))) + val task = item(tool = tool("task", name = "Task", status = "running"), subItems = listOf(nested)) + val complete = item(type = "tool", content = "new", tool = tool("read", status = "completed") + .copy(name = null, resultPreview = "new output")).copy(isSubagent = marked) + val shown = messageBlocks(message(items = listOf(task, complete)), true).single() as MessageBlock.Subagent + val card = (shown.children.single() as MessageBlock.Tools).tools.single() + assertEquals(ToolPhase.COMPLETED, card.phase) + assertEquals("new output", card.output) + assertEquals("Read", card.name) + assertEquals("original input", card.input) + assertEquals("running", task.subItems!!.single().tool!!.status) + } + } + + @Test + fun thinkingBlocksKeepTheirIdsWhenAToolArrivesBeforeThem() { + val reasoning = (1..3).map { item(type = "thinking", content = "reasoning $it") } + + val before = messageBlocks(message(items = reasoning), true).filterIsInstance() + val after = messageBlocks( + message(items = listOf(item(tool = tool("read", status = "running"))) + reasoning), + true, + ).filterIsInstance() + + assertEquals(before.map { it.id }, after.map { it.id }) + assertEquals(before.map { it.text }, after.map { it.text }) + } + + @Test + fun aTaskKeepsItsIdAndItsChildrenWhenAnEarlierItemArrivesLate() { + val task = item( + tool = tool("task", name = "Task", status = "running"), + subItems = listOf(item(type = "thinking", content = "child reasoning")), + ) + + val first = messageBlocks(message(items = listOf(task)), true).single() as MessageBlock.Subagent + val later = messageBlocks( + message(items = listOf(item(type = "thinking", content = "parent note"), task)), + true, + ).filterIsInstance().single() + + assertEquals(first.id, later.id) + assertEquals(first.children.map { it.id }, later.children.map { it.id }) + } + @Test fun theWaitingIndicatorIsOnlyForATurnThatHasProducedNothing() { assertTrue(isTyping(message(), true)) diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionPersistenceTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionPersistenceTest.kt index 9967821bf4..fdd482ece0 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionPersistenceTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionPersistenceTest.kt @@ -183,12 +183,62 @@ class RemoteSessionPersistenceTest { } val store = RemoteSessionStore.create(this, transport, "device-a", stores.stores) store.dispatch(RemoteSessionIntent.Open("server")); runCurrent() - assertEquals(1, stores.transcripts.replaces) + assertEquals(1, stores.transcripts.writes) val timeline = assertIs(store.state.value).timeline assertEquals("msg 7", timeline?.persistedMessages?.last()?.text) store.stop() } + @Test + fun aHistoryPageWritesTheTranscriptOnceWhenItSettles() = runTest { + // A page prepends to the transcript window, so no already written row can be + // reused and every record of the burst would rewrite all of it — on the + // thread that draws the screen. The page lands once, when its read settles. + val stores = MemoryPersistence() + val transport = PersistenceTransport().apply { + initialRecords = listOf( + richRecord("server", "t-new", 1, 1, "completed", "newest"), + buildJsonObject { + put("session_id", "server"); put("event", "relay://session-ready") + put("payload", buildJsonObject { put("hasMore", true) }) + }, + ) + } + val store = RemoteSessionStore.create(this, transport, "device-a", stores.stores) + store.dispatch(RemoteSessionIntent.Open("server")); runCurrent() + val afterOpen = stores.transcripts.writes + val beforePage = assertIs(store.state.value).timeline + val page = CompletableDeferred() + transport.loadOlderGate = page + store.dispatch(RemoteSessionIntent.LoadOlderMessages); runCurrent() + transport.records.emit(buildJsonObject { + put("session_id", "server"); put("event", "relay://session-history-started") + put("payload", buildJsonObject {}) + }) + val complete = richRecord("server", "t-old-0", 0, 1, "completed", "older 0") + val header = JsonObject(complete + ("payload" to JsonObject(complete.getValue("payload").jsonObject + .filterKeys { it != "round" && it != "item" } + ("id" to JsonPrimitive("turn/t-old-0"))))) + transport.records.emit(header); runCurrent() + assertEquals(beforePage, assertIs(store.state.value).timeline, + "A turn header must not expose the user bubble before the reply in the same page") + (0 until 6).forEach { index -> + transport.records.emit(richRecord("server", "t-old-$index", 0, 1, "completed", "older $index")) + runCurrent() + assertEquals(beforePage, assertIs(store.state.value).timeline, + "History records must be reduced without publishing intermediate user/assistant rows") + } + transport.records.emit(buildJsonObject { + put("session_id", "server"); put("event", "relay://session-ready") + put("payload", buildJsonObject { put("hasMore", false) }) + }) + runCurrent() + assertEquals(afterOpen, stores.transcripts.writes, "A page in flight must not rewrite the transcript per record") + page.complete(Unit); runCurrent() + assertEquals(afterOpen + 1, stores.transcripts.writes) + assertTrue(stores.transcripts.rows.getValue("device-a::server").any { it.text == "older 5" }) + store.dispatch(RemoteSessionIntent.Stop) + } + @Test fun streamingChunksShareOneTranscriptWriteAndSettleImmediately() = runTest { val stores = MemoryPersistence() @@ -197,16 +247,16 @@ class RemoteSessionPersistenceTest { } val store = RemoteSessionStore.create(this, transport, "device-a", stores.stores) store.dispatch(RemoteSessionIntent.Open("server")); runCurrent() - val afterOpen = stores.transcripts.replaces + val afterOpen = stores.transcripts.writes (2..6).forEach { revision -> transport.records.emit(richRecord("server", "t-2", 1, revision.toLong(), "inprogress", "chunk $revision")) runCurrent() } - assertEquals(afterOpen, stores.transcripts.replaces) + assertEquals(afterOpen, stores.transcripts.writes) advanceTimeBy(600); runCurrent() - assertEquals(afterOpen + 1, stores.transcripts.replaces) + assertEquals(afterOpen + 1, stores.transcripts.writes) transport.records.emit(richRecord("server", "t-2", 1, 7, "completed", "chunk done")); runCurrent() - assertEquals(afterOpen + 2, stores.transcripts.replaces) + assertEquals(afterOpen + 2, stores.transcripts.writes) assertEquals("chunk done", stores.transcripts.rows.getValue("device-a::server").last().text) store.stop() } @@ -472,10 +522,14 @@ private class MemorySessions : RemoteSessionListStore { private class MemoryTranscripts : RemoteTranscriptStore { val rows = mutableMapOf>() val cursors = mutableMapOf() - var replaces = 0 + var writes = 0 override fun load(deviceKey: String, sessionId: String) = rows["$deviceKey::$sessionId"].orEmpty() - override fun append(deviceKey: String, sessionId: String, startSeq: Int, messages: List) = Unit - override fun replace(deviceKey: String, sessionId: String, messages: List) { replaces++; rows["$deviceKey::$sessionId"] = messages } + override fun append(deviceKey: String, sessionId: String, startSeq: Int, messages: List) { + if (messages.isEmpty()) return + writes++ + rows["$deviceKey::$sessionId"] = rows["$deviceKey::$sessionId"].orEmpty().take(startSeq) + messages + } + override fun replace(deviceKey: String, sessionId: String, messages: List) { writes++; rows["$deviceKey::$sessionId"] = messages } override fun loadCursor(deviceKey: String, sessionId: String) = cursors["$deviceKey::$sessionId"] override fun saveCursor(deviceKey: String, sessionId: String, cursor: PersistedRemoteCursor) { cursors["$deviceKey::$sessionId"] = cursor } override fun delete(deviceKey: String, sessionId: String) { @@ -490,6 +544,8 @@ private class PersistenceTransport : RemoteCommandTransport, RemoteSessionStream var streamFailure: ((Throwable) -> Unit)? = null var caughtUp: (() -> Unit)? = null var subscriptions = 0 + var loadOlderGate: CompletableDeferred? = null + override suspend fun loadOlder(sessionId: String) { loadOlderGate?.await() } override suspend fun subscribe(sessionId: String, onError: (Throwable) -> Unit, onCaughtUp: () -> Unit): Flow = flow { subscriptions++ streamFailure = onError; caughtUp = onCaughtUp diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt index 7d0e18cb47..75efb0e700 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt @@ -114,6 +114,7 @@ class RemoteSessionStoreTest { put("session_id", "s-code"); put("event", "relay://session-gap"); put("payload", buildJsonObject { put("reason", "host stream restarted") }) }) transport.streamEvents.emit(richRecord("s-code", "new", 0, 1, "completed", "after restart")) + transport.streamEvents.emit(historyReady(false)) runCurrent() val timeline = assertIs(store.state.value).timeline!! assertEquals(listOf("after restart"), timeline.persistedMessages.filter { it.role == "assistant" }.map { it.text }) @@ -1465,7 +1466,7 @@ class RemoteSessionStoreTest { for (supported in listOf(false, true)) { val transport = FakeSessionTransport().apply { capabilitiesJson = if (supported) "[\"host_stream_v1\",\"dialog_steer_v1\"]" else "[\"host_stream_v1\"]" - initialEvents = listOf(richRecord("s-code", "active-1", 0, 1, "inprogress", "Working")) + initialEvents = listOf(richRecord("s-code", "t-1", 0, 1, "inprogress", "Working")) } val store = RemoteSessionStore(this, transport) store.dispatch(RemoteSessionIntent.Load); advanceUntilIdle() @@ -1475,12 +1476,23 @@ class RemoteSessionStoreTest { store.dispatch(RemoteSessionIntent.SendMessage("s-code", "steer me", listOf(image))); runCurrent() val sent = transport.commands.last { it.cmd in listOf("send_message", "steer_turn") } assertEquals(if (supported) "steer_turn" else "send_message", sent.cmd) - assertEquals(if (supported) "active-1" else null, sent.turnId) + assertEquals(if (supported) "t-1" else null, sent.turnId) assertEquals(if (supported) "steer me" else null, sent.displayContent) assertEquals(image.dataUrl, sent.imageContexts!!.single().dataUrl) val ready = assertIs(store.state.value) assertEquals("", ready.draft) - assertEquals("active-1", ready.timeline?.activeTurn?.turnId) + assertEquals("t-1", ready.timeline?.activeTurn?.turnId) + assertEquals(1, ready.timeline!!.conversationRows().count { + it.kind == ConversationRowKind.USER && it.text == "steer me" + }) + // A second submission into the same running turn is a distinct bubble. + store.dispatch(RemoteSessionIntent.SendMessage("s-code", "steer me", listOf(image))); runCurrent() + transport.streamEvents.emit(richRecord("s-code", "t-1", 0, 2, "inprogress", "Still working")) + runCurrent() + val twice = assertIs(store.state.value) + assertEquals(2, twice.timeline!!.conversationRows().count { + it.kind == ConversationRowKind.USER && it.text == "steer me" + }) store.stop() } } diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/SessionRecordReplicaTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/SessionRecordReplicaTest.kt index f7b8c85dee..75ce529b84 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/SessionRecordReplicaTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/SessionRecordReplicaTest.kt @@ -177,4 +177,81 @@ class SessionRecordReplicaTest { assertFails { replica.apply(record(1, "running", "private")) } assertTrue(replica.messages().isEmpty()) } + + private fun turnRecord(turn: String, index: Int, revision: Long, status: String, answer: String): JsonObject = buildJsonObject { + put("sessionId", "s"); put("id", "item/$turn"); put("revision", revision) + put("turn", buildJsonObject { + put("sessionId", "s"); put("turnId", turn); put("turnIndex", index); put("status", status) + put("userMessage", buildJsonObject { put("id", "${turn}u"); put("content", "question $turn"); put("timestamp", 1) }) + }) + put("round", buildJsonObject { put("id", "${turn}r"); put("turnId", turn); put("roundIndex", 0) }) + put("item", buildJsonObject { put("type", "text"); put("data", buildJsonObject { put("id", turn); put("content", answer); put("orderIndex", 0) }) }) + } + + @Test fun untouchedTurnsReuseTheirRenderedMessagesAndChangedOnesDoNot() { + val replica = SessionRecordReplica("s") + replica.apply(turnRecord("t", 0, 1, "running", "first")) + val first = replica.messages() + assertEquals(2, first.size) + replica.apply(turnRecord("t2", 1, 1, "completed", "second")) + val second = replica.messages() + assertEquals(4, second.size) + assertSame(first[0], second[0], "An untouched turn keeps its user message instance") + assertSame(first[1], second[1], "An untouched turn keeps its answer instance") + assertNotSame(first[1], second[3]) + replica.apply(turnRecord("t", 0, 2, "completed", "first done")) + val third = replica.messages() + assertEquals("first done", third[1].text) + assertNotSame(second[1], third[1], "A changed turn is rendered again") + assertSame(second[3], third[3], "Its neighbours are still reused") + } + + @Test fun terminalToolsReuseCacheAfterRetiringControls() { + for (status in listOf("completed", "failed", "cancelled", "rejected", "skipped")) { + val replica = SessionRecordReplica("s") + for (id in listOf("one", "two")) { + val source = record(1, "inprogress", "") + replica.apply(JsonObject(source + mapOf( + "id" to JsonPrimitive("item/$id"), + "item" to buildJsonObject { + put("type", "tool") + put("data", buildJsonObject { + put("id", id); put("toolName", "Read"); put("status", status) + put("toolCall", buildJsonObject { put("id", id) }) + }) + } + ))) + replica.applyControl(buildJsonObject { + put("turnId", "t") + put("toolEvent", buildJsonObject { + put("event_type", "ConfirmationNeeded"); put("tool_id", id); put("tool_name", "Read") + }) + }) + } + val first = replica.messages() + assertEquals(listOf(status, status), first[1].tools.orEmpty().map { it.status }) + repeat(3) { + assertSame(first[0], replica.messages()[0]) + assertSame(first[1], replica.messages()[1], "Terminal tools must not invalidate a read-only render") + } + replica.apply(turnRecord("other", 1, 1, "inprogress", "new turn")) + assertSame(first[1], replica.messages()[1], "A new turn must not re-render completed history") + } + } + + @Test fun controlOnlyChangesRefreshTheOwningTurn() { + val replica = SessionRecordReplica("s") + replica.apply(turnRecord("t", 0, 1, "inprogress", "working")) + replica.apply(turnRecord("t2", 1, 1, "completed", "other")) + val before = replica.messages() + assertTrue(before[1].tools.orEmpty().isEmpty()) + replica.applyControl(buildJsonObject { + put("turnId", "t") + put("toolEvent", buildJsonObject { put("event_type", "ConfirmationNeeded"); put("tool_id", "call"); put("tool_name", "Bash"); put("params", buildJsonObject { put("command", "pwd") }) }) + }) + val after = replica.messages() + assertEquals("call", after[1].tools.orEmpty().single().id) + assertNotSame(before[1], after[1], "A control event refreshes its own turn") + assertSame(before[3], after[3], "Turns without control changes are still reused") + } } diff --git a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClient.kt b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClient.kt index e865cc6deb..fe503a1b7d 100644 --- a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClient.kt +++ b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClient.kt @@ -1,5 +1,8 @@ package com.openbitfun.mobile.core.transport +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.filter @@ -156,6 +159,7 @@ public class CloudAccountClient internal constructor( private val client: HttpClient, private val log: TransportLog = TransportLog.None, legacyMobileDeviceNames: Set = emptySet(), + private val processingDispatcher: CoroutineDispatcher = Dispatchers.Default, private val realtimeFactory: (HttpClient, String, String) -> AccountRpcConnection = { client, url, token -> AccountRealtime(client, url, token, log) }, ) { private class Connection(val url: String, val token: String, val socket: AccountRpcConnection) @@ -177,11 +181,30 @@ public class CloudAccountClient internal constructor( /** Retain the closed binding so stale transports cannot reopen a signed-out account. */ private val historyReaders = mutableMapOf>>() + /** + * Asks the subscribed stream for one older page and waits for its answer. + * + * The stream answers every request it is handed, including by failing the + * ones it cannot serve before it ends, so a session that is still subscribed + * never leaves this waiting. A session without a live subscription is an + * error rather than a silently dropped tap. + */ public suspend fun loadOlderSession(targetDeviceId: String, sessionId: String) { - val channel = historyReaders[targetDeviceId + ":" + sessionId] ?: error("Session is not subscribed") + val channel = historyReaders[targetDeviceId + ":" + sessionId] + ?: error("Session is not subscribed") val request = CompletableDeferred() - channel.send(request) - request.await() + log.info("history request started session=${sessionId.take(24)}") + try { + channel.send(request) + request.await() + log.info("history request answered session=${sessionId.take(24)}") + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Throwable) { + // Ids and failure kinds only, like the stream reader's own reports. + log.warn("history request failed session=${sessionId.take(24)} type=${error::class.simpleName} message=${error.message}") + throw error + } } private val foregroundResumes = MutableSharedFlow(extraBufferCapacity = 1) public fun resumeSessionStreams() { foregroundResumes.tryEmit(0L) } @@ -360,19 +383,23 @@ public class CloudAccountClient internal constructor( if (target.isEmpty()) throw CloudAccountException(CloudAccountFailure.MALFORMED_RESPONSE) val socket = connection(relayUrl, session.token) val messageKey = peerMessageKey(relayUrl, session, target) - val nonce = DeviceIdentity.randomBytes(12) - val plain = RelayJson.encodeToString(RemoteCommand.serializer(), command).encodeToByteArray() - val encrypted = CloudAccountCipher.encrypt(plain, messageKey, nonce) - val payload = EncryptedPayload(Base64.Default.encode(encrypted), Base64.Default.encode(nonce)) + val payload = withContext(processingDispatcher) { + val nonce = DeviceIdentity.randomBytes(12) + val plain = RelayJson.encodeToString(RemoteCommand.serializer(), command).encodeToByteArray() + val encrypted = CloudAccountCipher.encrypt(plain, messageKey, nonce) + EncryptedPayload(Base64.Default.encode(encrypted), Base64.Default.encode(nonce)) + } val response = RelayJson.decodeFromJsonElement(EncryptedPayload.serializer(), socket.call(target, RelayJson.encodeToJsonElement(EncryptedPayload.serializer(), payload), timeoutMs)) val decoded = try { - CloudAccountCipher.decrypt( - decode(response.encryptedData), - messageKey, - decode(response.nonce), - ).decodeToString() + withContext(processingDispatcher) { + CloudAccountCipher.decrypt( + decode(response.encryptedData), messageKey, decode(response.nonce), + ).decodeToString() + } + } catch (cancelled: CancellationException) { + throw cancelled } catch (error: CloudAccountException) { throw error } catch (cause: Throwable) { @@ -381,7 +408,9 @@ public class CloudAccountClient internal constructor( throw CloudAccountException(CloudAccountFailure.MALFORMED_RESPONSE, null, cause) } return try { - RelayJson.decodeFromString(deserializer, decoded) + withContext(processingDispatcher) { RelayJson.decodeFromString(deserializer, decoded) } + } catch (cancelled: CancellationException) { + throw cancelled } catch (cause: Throwable) { log.error("device rpc undecodable cmd=${command.cmd} bytes=${decoded.length} ${decodeDetail(cause)}") throw CloudAccountException(CloudAccountFailure.MALFORMED_RESPONSE, null, cause) @@ -397,7 +426,8 @@ public class CloudAccountClient internal constructor( deserializer: DeserializationStrategy, token: String, timeoutMs: Long, - ): Response = execute(relayUrl, path, method, RelayJson.encodeToString(serializer, body), deserializer, token, timeoutMs) + ): Response = execute(relayUrl, path, method, + withContext(processingDispatcher) { RelayJson.encodeToString(serializer, body) }, deserializer, token, timeoutMs) private suspend fun requestWithoutBody( relayUrl: String, @@ -441,7 +471,9 @@ public class CloudAccountClient internal constructor( throw statusFailure(response.status.value) } return try { - RelayJson.decodeFromString(deserializer, text) + withContext(processingDispatcher) { RelayJson.decodeFromString(deserializer, text) } + } catch (cancelled: CancellationException) { + throw cancelled } catch (cause: Throwable) { // The body itself is never logged: it carries whatever the desktop // was asked for, and on this path that is the user's own sessions. diff --git a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/HostStream.kt b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/HostStream.kt index 508a8cd4e1..6df6c087f7 100644 --- a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/HostStream.kt +++ b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/HostStream.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.transform import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -41,7 +42,23 @@ public const val REMOTE_CAPABILITY_HOST_STREAM_V1: String = "host_stream_v1" /** Hosts keep hint subscriptions alive for 10 minutes; renew well before. */ internal const val HOST_STREAM_KEEPALIVE_MS: Long = 4 * 60 * 1000L +/** + * How many pages one history request may read while it has not yet shown the + * reader anything it did not already have. + * + * Host pages are cut by sequence, and a record's sequence is the moment it was + * last updated, so one long turn owns every record it produced: a page of + * history can be nothing but more records of the turn that is already on + * screen. Reading exactly one page per request then looks like "history loaded" + * while the transcript above stayed the same, and the newest turn of a long + * session can hold several pages by itself. The budget keeps that walk bounded + * (a request is a user gesture, not an unbounded download); the next request + * continues from where this one stopped. + */ +internal const val MAX_HISTORY_PAGES_PER_REQUEST: Int = 4 + /** Pseudo events the stream emits around the host's own records. */ +public const val STREAM_EVENT_HISTORY_STARTED: String = "relay://session-history-started" public const val STREAM_EVENT_READY: String = "relay://session-ready" public const val STREAM_EVENT_RESUMED: String = "relay://session-resumed" public const val STREAM_EVENT_GAP: String = "relay://session-gap" @@ -125,6 +142,17 @@ internal interface HostStreamReads { suspend fun unsubscribe() } +/** + * The turn a stream event belongs to, for the reader's "is this older than what + * the caller already has" check. `session-record` payloads always carry + * `turn.turnId`; control events belong to no turn and are ignored. + */ +internal fun streamEventTurnId(event: StreamEventWire): String? { + if (event.event != "session-record") return null + val turn = (event.payload as? JsonObject)?.get("turn") as? JsonObject ?: return null + return (turn["turnId"] as? JsonPrimitive)?.takeIf { it.isString }?.content +} + private class HostStreamReader( private val streamId: String, private val reads: HostStreamReads, @@ -136,12 +164,16 @@ private class HostStreamReader( var hasMore = false var truncated = false + /** Turns already emitted to the caller, i.e. already part of the transcript. */ + private val emittedTurns = mutableSetOf() + private suspend fun read(after: Long? = null, before: Long? = null, epoch: Long? = null): StreamPageWire = checkStreamPage(streamId, reads.read(after, before, epoch)) private suspend fun emitPage(page: StreamPageWire) { for (event in page.events) { emit(buildJsonObject { put("session_id", streamId); put("event", event.event); put("payload", event.payload) }) + streamEventTurnId(event)?.let { emittedTurns += it } } } @@ -194,18 +226,35 @@ private class HostStreamReader( } suspend fun loadOlder() { - if (!hasMore) return - val page = read(before = oldest, epoch = epoch) - if (page.epoch != epoch) { - emitGap() - resync() - throw IllegalStateException("Session history restarted on the host; reloaded from its latest page") + var pages = 0 + try { + while (hasMore && pages < MAX_HISTORY_PAGES_PER_REQUEST) { + val page = read(before = oldest, epoch = epoch) + if (page.epoch != epoch) { + emitGap() + resync() + throw IllegalStateException("Session history restarted on the host; reloaded from its latest page") + } + // A page that only repeats turns the transcript already has is not + // progress: keep reading until the caller gets an older turn, the + // host runs out of history, or this request's budget is spent. + val showsAnOlderTurn = page.events.any { event -> + streamEventTurnId(event)?.let { it !in emittedTurns } == true + } + if (pages == 0) emit(buildJsonObject { + put("session_id", streamId); put("event", STREAM_EVENT_HISTORY_STARTED) + put("payload", JsonObject(emptyMap())) + }) + emitPage(page) + page.events.firstOrNull()?.let { oldest = it.seq } + hasMore = page.hasMore + truncated = page.truncated + pages++ + if (showsAnOlderTurn) break + } + } finally { + if (pages > 0) emitReady() } - emitPage(page) - page.events.firstOrNull()?.let { oldest = it.seq } - hasMore = page.hasMore - truncated = page.truncated - emitReady() } fun hintIsNew(hint: StreamHint, target: String): Boolean = @@ -218,6 +267,8 @@ private sealed interface Wake { class Older(val request: CompletableDeferred) : Wake } +private data class StreamDelivery(val event: JsonObject? = null, val consumed: CompletableDeferred? = null) + /** * One host stream as a flow of `{session_id, event, payload}` objects. * @@ -230,6 +281,14 @@ private sealed interface Wake { * Every wake that re-reads the host after a recovery emits * [STREAM_EVENT_RESUMED] first; a host restart emits [STREAM_EVENT_GAP] before * the latest page is replayed, and each completed catch-up calls [onCaughtUp]. + * + * Every request on [olderRequests] is answered: one the lane cannot serve is + * failed before the flow ends, never left awaiting, because the caller waits for + * that answer independently of this flow. + * + * Wakes coalesce before the lane serves them: any number of pending hints cost + * one catch-up read, and a history request already queued is served in that same + * pass instead of behind one refresh per hint. */ internal fun hostStream( streamId: String, @@ -241,8 +300,20 @@ internal fun hostStream( onError: (Throwable) -> Unit, onCaughtUp: () -> Unit, keepaliveMs: Long = HOST_STREAM_KEEPALIVE_MS, -): Flow = channelFlow { - val reader = HostStreamReader(streamId, reads) { send(it) } +): Flow = channelFlow { + val reader = HostStreamReader(streamId, reads) { send(StreamDelivery(event = it)) } + // channelFlow.send only enqueues a record. Completion must follow reduction + // of all preceding records, including when the UI collector is slower. + suspend fun awaitConsumption() { + val consumed = CompletableDeferred() + send(StreamDelivery(consumed = consumed)) + consumed.await() + } + // Declared outside the lane so closing it can answer what the lane left. + val wakes = Channel(Channel.UNLIMITED) + // Requests the lane took off [wakes] and has not served yet. One sweep can + // hand it several, and every one of them is owed an answer. + val waiting = ArrayDeque>() // Set once a page was read with `subscribe`; before that there is nothing // on the host to release. var subscribed = false @@ -262,9 +333,9 @@ internal fun hostStream( retryMs = (retryMs * 2).coerceAtMost(30_000L) } } + awaitConsumption() onCaughtUp() - val wakes = Channel(Channel.UNLIMITED) launch { hints.collect { hint -> if (reader.hintIsNew(hint, target)) wakes.send(Wake.Dirty(false)) } } launch { reconnects.collect { wakes.send(Wake.Dirty(true)) } } launch { while (currentCoroutineContext().isActive) { delay(keepaliveMs); wakes.send(Wake.Dirty(false)) } } @@ -274,23 +345,43 @@ internal fun hostStream( var dirty = false var announce = false while (currentCoroutineContext().isActive) { - var older: CompletableDeferred? = null - when (val wake = wakes.receive()) { - is Wake.Dirty -> { dirty = true; announce = announce || wake.announce } - is Wake.Older -> older = wake.request + // Sweep whatever is already queued before serving anything. A + // streaming session fans out one hint per host event, and every hint + // carries the same meaning: read forward once. Serving exactly one + // wake per refresh turned a hint burst into a queue of refreshes, and + // a history request that arrived during the burst waited behind the + // whole queue, one catch-up read at a time. + var older = waiting.removeFirstOrNull() + var gathered = older != null + while (true) { + val queued = wakes.tryReceive().getOrNull() ?: break + gathered = true + when (queued) { + is Wake.Dirty -> { dirty = true; announce = announce || queued.announce } + is Wake.Older -> if (older == null) older = queued.request else waiting.addLast(queued.request) + } + } + // Nothing was queued: wait for a wake instead of re-running a refresh + // that just failed. Its retry wake is what carries the backoff. + if (!gathered) { + when (val wake = wakes.receive()) { + is Wake.Dirty -> { dirty = true; announce = announce || wake.announce } + is Wake.Older -> older = wake.request + } } try { if (dirty) { if (announce) { - send(buildJsonObject { put("session_id", streamId); put("event", STREAM_EVENT_RESUMED); put("payload", JsonObject(emptyMap())) }) + send(StreamDelivery(event = buildJsonObject { put("session_id", streamId); put("event", STREAM_EVENT_RESUMED); put("payload", JsonObject(emptyMap())) })) } reader.refresh() dirty = false announce = false + awaitConsumption() onCaughtUp() } older?.let { request -> - try { reader.loadOlder(); request.complete(Unit) } catch (cancelled: CancellationException) { throw cancelled } catch (error: Throwable) { request.completeExceptionally(error) } + try { reader.loadOlder(); awaitConsumption(); request.complete(Unit) } catch (cancelled: CancellationException) { throw cancelled } catch (error: Throwable) { awaitConsumption(); request.completeExceptionally(error) } } backoffMs = 1000L } catch (cancelled: CancellationException) { @@ -308,11 +399,36 @@ internal fun hostStream( } } } finally { - // Best effort: the host also drops idle subscriptions on its own. - if (subscribed) withContext(NonCancellable) { - try { withTimeout(5_000L) { reads.unsubscribe() } } catch (_: Throwable) {} + // The caller of a history request waits outside this flow, so the lane + // owes every request an answer: one left queued here would suspend its + // caller for the life of the app, and every later request would sit + // behind it. Both queues close first, so a request sent during this + // cleanup is rejected rather than parked in a lane that has ended. + withContext(NonCancellable) { + val closed = IllegalStateException("Session history stream is closed") + wakes.close() + while (true) { + val wake = wakes.tryReceive().getOrNull() ?: break + if (wake is Wake.Older) wake.request.completeExceptionally(closed) + } + while (true) { + val swept = waiting.removeFirstOrNull() ?: break + swept.completeExceptionally(closed) + } + olderRequests.close() + while (true) { + val queued = olderRequests.tryReceive().getOrNull() ?: break + queued.completeExceptionally(closed) + } + // Best effort: the host also drops idle subscriptions on its own. + if (subscribed) { + try { withTimeout(5_000L) { reads.unsubscribe() } } catch (_: Throwable) {} + } } } +}.transform { delivery -> + delivery.event?.let { emit(it) } + delivery.consumed?.complete(Unit) } /** Streams that a command transport can also read on demand from its device. */ diff --git a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/RpcPayload.kt b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/RpcPayload.kt index d32fcf9de7..fa21577238 100644 --- a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/RpcPayload.kt +++ b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/RpcPayload.kt @@ -7,6 +7,8 @@ import io.ktor.client.statement.bodyAsChannel import io.ktor.client.statement.bodyAsText import io.ktor.http.* import io.ktor.utils.io.readAvailable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.* @@ -14,7 +16,7 @@ import kotlinx.serialization.json.* internal class RpcPayload(private val http: HttpClient, relayUrl: String, private val token: String) { private val endpoint = "${relayUrl.trimEnd('/')}/v1/rpc/payloads" suspend fun uploadIfLarge(value: JsonElement): JsonElement { - val bytes = value.toString().encodeToByteArray() + val bytes = withContext(Dispatchers.Default) { value.toString().encodeToByteArray() } if (bytes.size <= INLINE_BYTES) return value if (bytes.size > MAX_BYTES) throw CloudAccountException(CloudAccountFailure.MALFORMED_RESPONSE) val response = withTimeout(120_000) { @@ -48,7 +50,9 @@ internal class RpcPayload(private val http: HttpClient, relayUrl: String, privat } val extra = ByteArray(1) if (offset != expected || channel.readAvailable(extra, 0, 1) != -1) throw CloudAccountException(CloudAccountFailure.MALFORMED_RESPONSE) - Json.parseToJsonElement(bytes.decodeToString(throwOnInvalidSequence = true)) + withContext(Dispatchers.Default) { + Json.parseToJsonElement(bytes.decodeToString(throwOnInvalidSequence = true)) + } } } private fun reference(value: JsonElement): Pair { diff --git a/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClientTest.kt b/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClientTest.kt index 860e407e4a..a8176c9050 100644 --- a/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClientTest.kt +++ b/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClientTest.kt @@ -413,7 +413,7 @@ class CloudAccountClientTest { assertTrue(request.url.encodedPath.endsWith("/key"), "HTTP must only read the authenticated public key") onKeyRead() json("""{"public_key":"${Base64.Default.encode(peerPublic)}"}""") - }), realtimeFactory = { _, _, token -> + }), processingDispatcher = kotlinx.coroutines.Dispatchers.Unconfined, realtimeFactory = { _, _, token -> assertEquals("token-1", token) FakeRpc(notifications, reply) }) diff --git a/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/HostStreamTest.kt b/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/HostStreamTest.kt index f34f194595..5f7901583e 100644 --- a/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/HostStreamTest.kt +++ b/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/HostStreamTest.kt @@ -16,6 +16,10 @@ private class FakeHost(private val streamId: String, private val pageSize: Int = var unsubscribed = 0 var failNext: Throwable? = null var rejectWith: String? = null + /** Parks a history read so a test can end the stream while the page is in flight. */ + var olderGate: CompletableDeferred? = null + /** Parks a forward read so a test can queue hints behind a catch-up in flight. */ + var forwardGate: CompletableDeferred? = null fun append(event: String, payload: JsonElement = JsonObject(emptyMap())): Long { val seq = nextSeq++ @@ -27,6 +31,8 @@ private class FakeHost(private val streamId: String, private val pageSize: Int = override suspend fun read(after: Long?, before: Long?, epoch: Long?): StreamPageWire { reads += Triple(after, before, epoch) + if (before != null) olderGate?.await() + if (after != null) forwardGate?.await() failNext?.let { failNext = null; throw it } rejectWith?.let { return StreamPageWire(resp = "error", message = it) } val page = when { @@ -50,6 +56,15 @@ private class FakeHost(private val streamId: String, private val pageSize: Int = class HostStreamTest { private fun names(events: List) = events.map { it.getValue("event").jsonPrimitive.content } + /** A `session-record` payload as the host writes it: every record names its turn. */ + private fun turnRecord(turn: String, n: Int) = buildJsonObject { + put("n", n); put("id", "item/$n"); put("turn", buildJsonObject { put("turnId", turn) }) + } + + private fun emittedTurnIds(events: List) = events + .filter { it.getValue("event").jsonPrimitive.content == "session-record" } + .map { it.getValue("payload").jsonObject.getValue("turn").jsonObject.getValue("turnId").jsonPrimitive.content } + private fun TestScope.open( host: FakeHost, hints: Flow = emptyFlow(), reconnects: Flow = emptyFlow(), older: Channel> = Channel(), onError: (Throwable) -> Unit = {}, onCaughtUp: () -> Unit = {}, @@ -62,6 +77,66 @@ class HostStreamTest { return received to job } + @Test fun completionWaitsUntilTheCollectorConsumesTheWholePage() = runTest { + val host = FakeHost("s1", pageSize = 2) + repeat(4) { host.append("session-record", turnRecord("turn-$it", it)) } + val older = Channel>(Channel.UNLIMITED) + var caughtUp = false + var gate = CompletableDeferred() + var received = 0 + val job = backgroundScope.launch { + hostStream("s1", "desktop-1", emptyFlow(), emptyFlow(), host, older, + { throw it }, { caughtUp = true }).collect { + gate.await() + received++ + } + } + runCurrent() + assertFalse(caughtUp, "Enqueued records are not yet a hydrated transcript") + gate.complete(Unit); runCurrent() + assertTrue(caughtUp) + assertEquals(3, received) + gate = CompletableDeferred() + val page = CompletableDeferred() + older.send(page); runCurrent() + assertFalse(page.isCompleted, "History loading must cover reducer consumption, not only the RPC") + gate.complete(Unit); runCurrent() + assertTrue(page.isCompleted) + assertEquals(7, received) + job.cancel(); runCurrent() + } + + @Test fun failedLaterPageSettlesReceivedRecordsBeforeReportingFailure() = runTest { + val host = FakeHost("s1", pageSize = 2) + repeat(10) { host.append("session-record", turnRecord("same-turn", it)) } + var olderReads = 0 + val reads = object : HostStreamReads { + override suspend fun read(after: Long?, before: Long?, epoch: Long?): StreamPageWire { + if (before != null && ++olderReads == 2) error("later page unavailable") + return host.read(after, before, epoch) + } + override suspend fun unsubscribe() = host.unsubscribe() + } + val older = Channel>(Channel.UNLIMITED) + val received = mutableListOf() + var gate = CompletableDeferred().apply { complete(Unit) } + val job = backgroundScope.launch { + hostStream("s1", "desktop-1", emptyFlow(), emptyFlow(), reads, older, { throw it }, {}).collect { + gate.await(); received += it + } + } + runCurrent() + gate = CompletableDeferred() + val request = CompletableDeferred() + older.send(request); runCurrent() + assertFalse(request.isCompleted, "Failure cannot release loading before queued records are consumed") + gate.complete(Unit); runCurrent() + assertTrue(request.isCancelled) + assertEquals(listOf(STREAM_EVENT_HISTORY_STARTED, "session-record", "session-record", STREAM_EVENT_READY), names(received.takeLast(4))) + assertTrue(received.last().getValue("payload").jsonObject.getValue("hasMore").jsonPrimitive.boolean) + job.cancel(); runCurrent() + } + @Test fun opensAtTheLatestPageAndReportsHistory() = runTest { val host = FakeHost("s1") repeat(5) { host.append("session-record", buildJsonObject { put("n", it) }) } @@ -119,7 +194,9 @@ class HostStreamTest { @Test fun olderPagesAreReadBeforeTheOldestKnownSequence() = runTest { val host = FakeHost("s1") - repeat(7) { host.append("session-record", buildJsonObject { put("n", it + 1) }) } + // One turn per record: every older page shows a turn the transcript does + // not have yet, so each request reads exactly the page it was asked for. + repeat(7) { host.append("session-record", turnRecord("t$it", it + 1)) } val older = Channel>() val (received, _) = open(host, older = older) runCurrent() @@ -139,6 +216,115 @@ class HostStreamTest { assertEquals(reads, host.reads.size, "no history left means no read") } + @Test fun oneHistoryRequestKeepsReadingUntilAPageShowsAnOlderTurn() = runTest { + val host = FakeHost("s1") + repeat(3) { host.append("session-record", turnRecord("t1", it)) } + repeat(9) { host.append("session-record", turnRecord("t2", it + 3)) } + val older = Channel>() + val (received, _) = open(host, older = older) + runCurrent() + assertEquals(1, host.reads.size, "the opening page is the newest one") + + val request = CompletableDeferred(); older.send(request); runCurrent() + assertTrue(request.isCompleted) + // The newest page holds only t2 records, and so do the two pages behind + // it: reading one page per request would show the reader nothing new. + assertEquals(listOf(10L, 7L, 4L), host.reads.drop(1).map { it.second }) + assertEquals( + listOf("t2", "t2", "t2", "t2", "t2", "t2", "t2", "t2", "t2", "t1", "t1", "t1"), + emittedTurnIds(received), + ) + assertEquals(false, received.last().getValue("payload").jsonObject.getValue("hasMore").jsonPrimitive.boolean) + } + + @Test fun aHistoryRequestStopsAtItsPageBudgetAndTheNextOneContinues() = runTest { + val host = FakeHost("s1") + repeat(3) { host.append("session-record", turnRecord("t1", it)) } + repeat(21) { host.append("session-record", turnRecord("t2", it + 3)) } + val older = Channel>() + val (received, _) = open(host, older = older) + runCurrent() + + val first = CompletableDeferred(); older.send(first); runCurrent() + assertTrue(first.isCompleted) + assertEquals(listOf(22L, 19L, 16L, 13L), host.reads.drop(1).map { it.second }) + assertTrue(emittedTurnIds(received).all { it == "t2" }, "the budget stops before t1 is reached") + assertEquals(true, received.last().getValue("payload").jsonObject.getValue("hasMore").jsonPrimitive.boolean) + + val second = CompletableDeferred(); older.send(second); runCurrent() + assertTrue(second.isCompleted) + assertEquals(listOf(10L, 7L, 4L), host.reads.drop(5).map { it.second }) + assertTrue(emittedTurnIds(received).contains("t1"), "the next request continues where the budget stopped") + assertEquals(false, received.last().getValue("payload").jsonObject.getValue("hasMore").jsonPrimitive.boolean) + } + + @Test fun aHintBurstCostsOneCatchUpAndDoesNotDelayAQueuedHistoryRequest() = runTest { + val host = FakeHost("s1") + repeat(7) { host.append("session-record", turnRecord("t$it", it + 1)) } + val hints = MutableSharedFlow() + val older = Channel>() + val (_, _) = open(host, hints = hints, older = older) + runCurrent() + + // A streaming host fans out one hint per event. Park the catch-up the + // first hint starts, so the rest pile up behind it exactly as they do + // while a turn is streaming. + val gate = CompletableDeferred() + host.forwardGate = gate + repeat(4) { host.append("session-record", turnRecord("t9", 90 + it)); hints.emit(StreamHint("desktop-1", "s1", host.epoch, host.cursor)) } + runCurrent() + assertEquals(1, host.reads.count { it.first != null }, "the hints queue behind one catch-up read") + val readsBeforeGate = host.reads.size + + val request = CompletableDeferred(); older.send(request); runCurrent() + gate.complete(Unit); runCurrent() + + assertTrue(request.isCompleted, "the queued request is answered as soon as the lane is free") + val refreshed = host.reads.drop(readsBeforeGate).count { it.first != null } + assertTrue(refreshed <= 2, "four queued hints cost one catch-up, not one each: reads=$refreshed") + assertEquals(5L, host.reads.first { it.second != null }.second, "the request reads before the oldest known sequence") + } + + @Test fun aHistoryRequestTheLaneNeverReachedIsFailedWhenTheStreamEnds() = runTest { + val host = FakeHost("s1") + repeat(7) { host.append("session-record", buildJsonObject { put("n", it) }) } + val gate = CompletableDeferred() + host.olderGate = gate + val older = Channel>() + val (_, job) = open(host, older = older) + runCurrent() + val inFlight = CompletableDeferred() + older.send(inFlight); runCurrent() + assertFalse(inFlight.isCompleted, "the page is still parked on the host") + val queued = CompletableDeferred() + older.send(queued); runCurrent() + assertFalse(queued.isCompleted) + + job.cancel(); runCurrent() + assertTrue(inFlight.isCompleted) + assertTrue(queued.isCompleted, "a request the lane cannot serve must still be answered") + assertFailsWith { queued.getCompleted() } + gate.complete(Unit); runCurrent() + } + + @Test fun aHistoryRequestQueuedBeforeTheLaneStartsIsFailedWhenTheOpeningReadIsFatal() = runTest { + val host = FakeHost("s1") + host.append("session-record") + host.failNext = CloudAccountException(CloudAccountFailure.AUTHENTICATION) + val older = Channel>(Channel.UNLIMITED) + val request = CompletableDeferred() + older.send(request) + val fatal = CompletableDeferred() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + try { hostStream("s1", "desktop-1", emptyFlow(), emptyFlow(), host, older, {}, {}).collect() } + catch (error: Throwable) { fatal.complete(error) } + } + runCurrent() + assertTrue(request.isCompleted, "a lane that never started still answers what was queued for it") + assertFailsWith { request.getCompleted() } + assertEquals(CloudAccountFailure.AUTHENTICATION, (fatal.getCompleted() as CloudAccountException).failure) + } + @Test fun openingRetriesTransientFailuresWithoutEmittingAndStopsAtAuthentication() = runTest { val host = FakeHost("s1") host.append("session-record") diff --git a/src/apps/mobile/shared/core-transport/src/jvmTest/kotlin/com/openbitfun/mobile/core/transport/RpcProcessingThreadTest.kt b/src/apps/mobile/shared/core-transport/src/jvmTest/kotlin/com/openbitfun/mobile/core/transport/RpcProcessingThreadTest.kt new file mode 100644 index 0000000000..3bc7123dbf --- /dev/null +++ b/src/apps/mobile/shared/core-transport/src/jvmTest/kotlin/com/openbitfun/mobile/core/transport/RpcProcessingThreadTest.kt @@ -0,0 +1,70 @@ +package com.openbitfun.mobile.core.transport + +import com.openbitfun.mobile.core.crypto.CloudAccountCipher +import com.openbitfun.mobile.core.crypto.DeviceIdentity +import com.openbitfun.mobile.core.protocol.CommandStatusResponse +import com.openbitfun.mobile.core.protocol.EncryptedPayload +import com.openbitfun.mobile.core.protocol.RelayJson +import com.openbitfun.mobile.core.protocol.RemoteCommand +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpHeaders +import io.ktor.http.headersOf +import java.util.concurrent.Executors +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.encodeToJsonElement +import kotlin.io.encoding.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotSame + +class RpcProcessingThreadTest { + @Test + fun responseDecodingDoesNotOccupyTheCallingUiThread() { + Executors.newSingleThreadExecutor().asCoroutineDispatcher().use { ui -> + runBlocking(ui) { + val uiThread = Thread.currentThread() + val master = ByteArray(32) { 7 } + val peer = ByteArray(32) { 11 } + val key = DeviceIdentity.messageKey(peer, DeviceIdentity.publicKey(master)) + val nonce = ByteArray(12) { 3 } + // Use the production serializer for the wire shape. + val plain = RelayJson.encodeToString(CommandStatusResponse.serializer(), CommandStatusResponse("ok")) + val reply = RelayJson.encodeToJsonElement(EncryptedPayload.serializer(), EncryptedPayload( + Base64.Default.encode(CloudAccountCipher.encrypt(plain.encodeToByteArray(), key, nonce)), + Base64.Default.encode(nonce), + )) + val http = relayHttpClient(MockEngine { + respond("""{"public_key":"${Base64.Default.encode(DeviceIdentity.publicKey(peer))}"}""", + headers = headersOf(HttpHeaders.ContentType, "application/json")) + }) + try { + val client = CloudAccountClient(http, realtimeFactory = { _, _, _ -> + object : AccountRpcConnection { + override val notifications = emptyFlow() + override suspend fun call(target: String, params: JsonElement, timeoutMs: Long) = reply + override fun close() {} + } + }) + val serializer = CommandStatusResponse.serializer() + val checked = object : DeserializationStrategy by serializer { + override fun deserialize(decoder: Decoder): CommandStatusResponse { + assertNotSame(uiThread, Thread.currentThread(), "RPC decoding must leave the UI caller") + return serializer.deserialize(decoder) + } + } + val result = client.deviceRpc(DEFAULT_CLOUD_RELAY_URL, CloudAccountSession("token", "user", master), + "desktop", RemoteCommand(cmd = "list_sessions"), checked, 5_000) + assertEquals("ok", result.resp) + assertEquals(uiThread, Thread.currentThread(), "UI state delivery must resume on its caller") + } finally { http.close() } + } + } + } +} diff --git a/src/mobile-web/tests/host-stream.test.mjs b/src/mobile-web/tests/host-stream.test.mjs index 3723411650..8214da549a 100644 --- a/src/mobile-web/tests/host-stream.test.mjs +++ b/src/mobile-web/tests/host-stream.test.mjs @@ -21,6 +21,8 @@ class FakeHost { return { readStream: async request => { this.reads.push(request); + // A forward catch-up can be parked so a test can queue hints behind it. + if (request.after !== undefined && options.forwardGate) await options.forwardGate; if (options.fail?.(request)) throw new Error(options.fail(request)); if (request.subscribe) this.subscribers.add(request.stream_id); const limit = request.limit ?? 2; @@ -55,7 +57,9 @@ async function open(host, extra = {}) { } test('opening reads the latest page and reports history state; older pages walk backwards', async () => { - const host = new FakeHost([{ id: 'turn/1' }, { id: 'turn/2' }, { id: 'turn/3' }, { id: 'turn/4' }, { id: 'turn/5' }]); + // One turn per record: every older page shows a turn the transcript does not + // have yet, so each request reads exactly the page it was asked for. + const host = new FakeHost([1, 2, 3, 4, 5].map(n => ({ id: `turn/${n}`, turn: { turnId: `t${n}` } }))); const f = await open(host); assert.deepEqual(f.seen.map(e => e.payload.id), ['turn/4', 'turn/5']); assert.equal(f.seen[0].session_id, 's1'); @@ -77,6 +81,39 @@ test('opening reads the latest page and reports history state; older pages walk assert.equal(f.sig.size, 0, 'closing releases hint and reconnect listeners'); }); +test('one history request reads past pages of the turn already on screen', async () => { + const host = new FakeHost([ + ...[1, 2].map(n => ({ id: `a${n}`, turn: { turnId: 't1' } })), + ...[1, 2, 3, 4, 5, 6].map(n => ({ id: `b${n}`, turn: { turnId: 't2' } })), + ]); + const f = await open(host); + assert.deepEqual(f.seen.map(e => e.payload.id), ['b5', 'b6']); + await f.stream.loadOlder(); + // The newest pages are more of t2, the turn already on screen: one request + // reads through them instead of reporting a load that shows nothing new. + assert.deepEqual(host.reads.slice(1).map(read => read.before), [7, 5, 3]); + assert.deepEqual(f.seen.map(e => e.payload.turn.turnId), ['t2', 't2', 't2', 't2', 't2', 't2', 't1', 't1']); + assert.equal(f.history.at(-1).hasMore, false); + f.stream.close(); +}); + +test('a history request stops at its page budget and the next one continues', async () => { + const host = new FakeHost([ + ...[1, 2].map(n => ({ id: `a${n}`, turn: { turnId: 't1' } })), + ...Array.from({ length: 16 }, (_, i) => ({ id: `b${i + 1}`, turn: { turnId: 't2' } })), + ]); + const f = await open(host); + await f.stream.loadOlder(); + assert.deepEqual(host.reads.slice(1).map(read => read.before), [17, 15, 13, 11]); + assert.equal(f.seen.some(e => e.payload.turn.turnId === 't1'), false, 'the budget stops before t1 is reached'); + assert.equal(f.history.at(-1).hasMore, true); + await f.stream.loadOlder(); + assert.deepEqual(host.reads.slice(5).map(read => read.before), [9, 7, 5, 3]); + assert.ok(f.seen.some(e => e.payload.turn.turnId === 't1'), 'the next request continues where the budget stopped'); + assert.equal(f.history.at(-1).hasMore, false); + f.stream.close(); +}); + test('hints for this host and stream trigger a forward catch-up across pages; foreign or stale hints do not', async () => { const host = new FakeHost([{ id: 'turn/1' }]); const f = await open(host); @@ -96,6 +133,41 @@ test('hints for this host and stream trigger a forward catch-up across pages; fo f.stream.close(); }); +test('a hint burst costs one catch-up and does not delay a queued history request', async t => { + const host = new FakeHost([1, 2, 3, 4, 5].map(n => ({ id: `turn/${n}`, turn: { turnId: `t${n}` } }))); + const gate = {}; + const forwardGate = new Promise(resolve => { gate.open = resolve; }); + const f = await open(host, { forwardGate }); + t.after(() => f.stream.close()); + // A streaming host fans out one hint per event. Park the catch-up the first + // hint starts, so the rest pile up behind it exactly as they do while a turn + // is streaming, and queue a history request behind all of them. + host.append({ id: 'turn/6', turn: { turnId: 't6' } }); + f.sig.hint({ sourceDeviceId: 'desktop', stream_id: 's1', epoch: host.epoch, cursor: host.cursor }); + await tick(); + for (let n = 7; n <= 10; n++) { + host.append({ id: `turn/${n}`, turn: { turnId: `t${n}` } }); + f.sig.hint({ sourceDeviceId: 'desktop', stream_id: 's1', epoch: host.epoch, cursor: host.cursor }); + } + await tick(); + assert.equal(host.reads.filter(read => read.after !== undefined).length, 1, 'the hints queue behind one catch-up read'); + + const loading = f.stream.loadOlder(); + await tick(); + gate.open(); + await loading; + await settle(); + + const history = host.reads.findIndex(read => read.before !== undefined); + assert.ok(history > 0, 'the request reached the host'); + // One catch-up over five new events is three pages at this page size, so the + // history request waits for exactly those reads: the four hints that arrived + // while it was parked merged into it and into a single later refresh. + assert.deepEqual(host.reads.slice(0, history).filter(read => read.after !== undefined).map(read => read.after), [5, 7, 9]); + assert.equal(host.reads[history].before, 4, 'the history request runs right after that catch-up'); + assert.deepEqual(host.reads.slice(history + 1).map(read => read.after), [10], 'the burst left one merged refresh, not one per hint'); +}); + test('a host restart is announced as a gap before the latest page is replayed', async () => { const host = new FakeHost([{ id: 'turn/1' }, { id: 'turn/2' }]); const f = await open(host); diff --git a/src/shared/relay-transport/HostStream.ts b/src/shared/relay-transport/HostStream.ts index b8e316abb5..702a3f8ddb 100644 --- a/src/shared/relay-transport/HostStream.ts +++ b/src/shared/relay-transport/HostStream.ts @@ -13,6 +13,19 @@ export const UNSUPPORTED_HOST_MESSAGE = 'The controlled device runs an older Ope /** Hosts keep hint subscriptions alive for 10 minutes; renew well before. */ const KEEPALIVE_MS = 4 * 60 * 1000; +/** How many pages one history request may read while it has not yet shown the + * reader anything it did not already have. + * + * Host pages are cut by sequence, and a record's sequence is the moment it was + * last updated, so one long turn owns every record it produced: a page of + * history can be nothing but more records of the turn that is already on + * screen. Reading exactly one page per request then looks like "history loaded" + * while the transcript above stayed the same. The budget keeps that walk bounded + * (a request is a user gesture, not an unbounded download); the next request + * continues from where this one stopped. Mirrors `MAX_HISTORY_PAGES_PER_REQUEST` + * in the mobile core transport. */ +const MAX_HISTORY_PAGES_PER_REQUEST = 4; + export interface StreamEvent { seq: number; event: string; payload: unknown } export interface StreamPage { stream_id: string; @@ -33,6 +46,15 @@ export interface StreamReadRequest { } export interface StreamHint { sourceDeviceId: string; stream_id: string; epoch: number; cursor: number } +/** The turn a stream event belongs to, for the reader's "is this older than what + * the caller already has" check. `session-record` payloads always carry + * `turn.turnId`; control events belong to no turn and are ignored. */ +export function streamEventTurnId(event: StreamEvent): string | null { + if (event.event !== 'session-record') return null; + const turn = (event.payload as { turn?: { turnId?: unknown } } | undefined)?.turn; + return typeof turn?.turnId === 'string' ? turn.turnId : null; +} + /** Event as consumed by product reducers; `session_id` is the stream id. */ export interface SessionEvent { session_id: string; event: string; payload: unknown } export interface SessionHistoryState { hasMore: boolean; oldestSeq: number; cursor: number; truncated: boolean } @@ -112,6 +134,8 @@ class Reader { oldest = 1; hasMore = false; truncated = false; + /** Turns already emitted to the caller, i.e. already part of the transcript. */ + private readonly emittedTurns = new Set(); constructor(private readonly options: HostStreamOptions) {} private async read(request: Omit): Promise { @@ -122,6 +146,8 @@ class Reader { private emitPage(page: StreamPage): void { for (const event of page.events) { this.options.onEvent({ session_id: this.options.streamId, event: event.event, payload: event.payload }); + const turnId = streamEventTurnId(event); + if (turnId !== null) this.emittedTurns.add(turnId); } } private emitHistory(): void { @@ -160,18 +186,31 @@ class Reader { await this.resync(); } async loadOlder(): Promise { - if (!this.hasMore) return; - const page = await this.read({ before: this.oldest, epoch: this.epoch }); - if (page.epoch !== this.epoch) { - this.options.onGap?.('host stream restarted'); - await this.resync(); - throw new Error('Session history restarted on the host; reloaded from its latest page'); + let pages = 0; + while (this.hasMore && pages < MAX_HISTORY_PAGES_PER_REQUEST) { + const page = await this.read({ before: this.oldest, epoch: this.epoch }); + if (page.epoch !== this.epoch) { + this.options.onGap?.('host stream restarted'); + await this.resync(); + throw new Error('Session history restarted on the host; reloaded from its latest page'); + } + // A page that only repeats turns the transcript already has is not + // progress: keep reading until the caller gets an older turn, the host + // runs out of history, or this request's budget is spent. + const showsAnOlderTurn = page.events.some(event => { + const turnId = streamEventTurnId(event); + return turnId !== null && !this.emittedTurns.has(turnId); + }); + this.emitPage(page); + if (page.events[0]) this.oldest = page.events[0].seq; + this.hasMore = page.has_more; + this.truncated = page.truncated; + pages++; + if (showsAnOlderTurn) break; } - this.emitPage(page); - if (page.events[0]) this.oldest = page.events[0].seq; - this.hasMore = page.has_more; - this.truncated = page.truncated; - this.emitHistory(); + // One settled report for the whole request: the caller asked once, so it + // hears one history state, not one per page. + if (pages > 0) this.emitHistory(); } hintIsNew(hint: StreamHint): boolean { return hint.sourceDeviceId === this.options.target && hint.stream_id === this.options.streamId From fc6d7df98ca4f70cf740a7db86ca11a164310a0a Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Mon, 21 Sep 2026 19:18:13 +0800 Subject: [PATCH 2/2] fix(mobile): start the ios transcript at the top overlay inset The timeline carried the design token `timeline_top_padding` (22) as its own top padding, but the top overlay is already reserved through `safeAreaInset`, and that inset ends with the 28pt header edge fade. The first message therefore began 50pt below the header band at rest, while Android's `contentPadding(top = topInset)` and HarmonyOS's `contentStartOffset(topInset)` start it at 28pt. Dropping the extra padding gives the three clients one content start and leaves the fade doing what it is for: softening text that scrolls under the band. The token stays in the contract, where only the two design galleries consume it. --- .../ios/OpenBitFun/Features/Chat/ChatTimelineView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift index 4688fcef24..c910b75ef8 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift @@ -52,7 +52,9 @@ struct ChatTimelineView: View { OpenBitFunTheme.transparent.frame(height: 1).id("timeline-bottom") } .padding(.horizontal, MobileDesignGeometry.contentGutter) - .padding(.top, MobileDesignGeometry.timelineTopPadding) + // No top padding of its own: the top overlay's inset already ends + // where the header's fade does, which is the same content start + // Android's contentPadding and HarmonyOS's contentStartOffset use. .padding(.bottom, 14 + scrollController.historyBottomSpace) .background(TimelineScrollProbe(controller: scrollController)) }