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..c910b75ef8 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) } @@ -69,59 +52,41 @@ struct ChatTimelineView: View { OpenBitFunTheme.transparent.frame(height: 1).id("timeline-bottom") } .padding(.horizontal, MobileDesignGeometry.contentGutter) - .padding(.top, MobileDesignGeometry.timelineTopPadding) - .padding(.bottom, 14) + // 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)) } .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 +109,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 +144,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 +293,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 +1063,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 +1081,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 +1091,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