From 9f874c2c3eba123b2b8f5b9fa442f976cb8e96b3 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Mon, 21 Sep 2026 19:59:37 +0800 Subject: [PATCH 1/6] fix(mobile): wait for the host's transcript, not this device's copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reopening a session published the transcript stored on this device before the host answered for it. A stored copy stops wherever its last write stopped — inside the turn that was running when the app went away, because a streaming assistant message is not durable yet — so the pane showed a half-finished turn and every gate read "the transcript arrived": the skeleton ended, the scrolling settled, and the composer offered itself as if the session were all there. The rows are still shown at once; what changes is that the state now says who they belong to. - core-domain: `ChatTranscriptOrigin` on `ChatTimelineState`, CACHE for rows restored from this device's copy and HOST once the host replays or streams them. `reset()` goes back to CACHE with the empty state. - core-feature: `open()` marks CACHE where it publishes the stored copy; the stream, the caught-up callback, and a `relay://session-gap` replay mark HOST. `persistTranscript` writes back only a HOST view, so a restored copy can never be stored as though the host had confirmed it. - core-feature: `transcriptUnconfirmed()` is the app-visible spelling of that fact, so a platform app can act on it without depending on :core-domain. - iOS and Android: the open wait ends on the host's rows rather than on any rows, the skeleton stays hidden when the pane already has the stored copy, and that copy carries a "syncing" row until the host's transcript lands. HarmonyOS is unchanged: it never publishes this device's stored copy on open (its durable stream is the only source), so it has no restored-transcript state to label. --- .../app/ui/chat/ConversationTimelineView.kt | 81 ++++++++++++++----- .../mobile/app/ui/chat/ConversationView.kt | 4 + .../app/src/main/res/values-zh/strings.xml | 1 + .../app/src/main/res/values/strings.xml | 1 + .../ui/chat/ConversationScrollPolicyTest.kt | 10 +-- .../Features/Chat/ChatTimelineView.swift | 15 ++++ .../MobileAppModel+RemoteSession.swift | 17 +++- .../Infrastructure/MobileAppModel.swift | 4 + .../mobile/core/domain/ChatTimelineStore.kt | 33 ++++++++ .../core/domain/ChatTimelineStoreTest.kt | 20 +++++ .../session/ConversationPresentation.kt | 13 +++ .../feature/session/RemoteSessionStore.kt | 20 +++++ .../ConversationModelPresentationTest.kt | 2 + .../session/ConversationPresentationTest.kt | 12 +++ .../session/RemoteSessionPersistenceTest.kt | 53 ++++++++++++ 15 files changed, 261 insertions(+), 25 deletions(-) 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 492decaf8d..b3f2c10b7a 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 @@ -4,17 +4,23 @@ import com.openbitfun.mobile.core.feature.session.HistoryLoadState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.interaction.DragInteraction import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -59,12 +65,13 @@ internal object ConversationScrollPolicy { stickToBottom && hasRows /** - * The LazyColumn puts the "load older messages" header at index zero when - * [hasMoreMessages] is true, so the real tail is one past [rowCount] instead - * of `rowCount - 1`. + * The LazyColumn puts one leading header in front of the messages when the + * transcript has an older page to load or has not been confirmed by the + * host yet, so the real tail is one past [rowCount] instead of + * `rowCount - 1`. */ - fun lastItemIndex(rowCount: Int, hasMoreMessages: Boolean): Int = - if (hasMoreMessages) rowCount else (rowCount - 1).coerceAtLeast(0) + fun lastItemIndex(rowCount: Int, hasLeadingItem: Boolean): Int = + if (hasLeadingItem) rowCount else (rowCount - 1).coerceAtLeast(0) } /** One automatic page per deliberate drag; layout and bounce cannot re-arm it. */ @@ -84,6 +91,12 @@ internal class HistoryPageArrivalTracker { internal fun ConversationTimelineView( rows: List, hasMoreMessages: Boolean, + /** + * The rows on screen are this device's stored copy rather than the host's + * transcript: a reopened session shows them at once, and the host has not + * answered for it yet. See `ChatTranscriptOrigin`. + */ + transcriptUnconfirmed: Boolean = false, onLoadOlder: () -> Unit, enabled: Boolean, onApproveTool: (String, String?) -> Unit, @@ -111,6 +124,9 @@ internal fun ConversationTimelineView( val listState = rememberLazyListState() var stickToBottom by rememberSaveable { mutableStateOf(true) } val atBottom by remember(listState) { derivedStateOf { !listState.canScrollForward } } + // One header slot holds both leading rows, so the scroll policy counts an + // item, not a row. + val hasLeadingItem = hasMoreMessages || transcriptUnconfirmed val historyArrival = remember { HistoryPageArrivalTracker() } var userDragging by remember { mutableStateOf(false) } @@ -132,12 +148,12 @@ internal fun ConversationTimelineView( ) } } - LaunchedEffect(rows, stickToBottom, hasMoreMessages) { + LaunchedEffect(rows, stickToBottom, hasLeadingItem) { if (ConversationScrollPolicy.shouldScrollToBottom(stickToBottom, rows.isNotEmpty())) { // A large offset positions the item's bottom at the viewport tail directly; // unlike scrollToItem(index), it does not briefly expose the item's top. listState.scrollToItem( - ConversationScrollPolicy.lastItemIndex(rows.size, hasMoreMessages), + ConversationScrollPolicy.lastItemIndex(rows.size, hasLeadingItem), scrollOffset = Int.MAX_VALUE, ) } @@ -180,19 +196,46 @@ internal fun ConversationTimelineView( ), verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Bottom), ) { - if (hasMoreMessages) { + if (hasLeadingItem) { item(key = "load-older-messages") { - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - TextButton( - onClick = { historyArrival.cancelArrival(); stickToBottom = false; onLoadOlder() }, - enabled = enabled && historyLoadState != HistoryLoadState.LOADING, - colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurfaceVariant), - ) { - Text(stringResource(when (historyLoadState) { - HistoryLoadState.LOADING -> R.string.chat_loading_older_messages - HistoryLoadState.FAILED -> R.string.chat_load_older_failed - else -> R.string.chat_load_older_messages - })) + Column(modifier = Modifier.fillMaxWidth()) { + if (transcriptUnconfirmed) { + // These rows stop where this device's last write stopped, + // inside the turn that was running when the app went away. + // Say the rest is on its way instead of letting a + // half-finished turn read as the session. + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(7.dp)) + Text( + text = stringResource(R.string.chat_transcript_syncing), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (hasMoreMessages) { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + TextButton( + onClick = { historyArrival.cancelArrival(); stickToBottom = false; onLoadOlder() }, + enabled = enabled && historyLoadState != HistoryLoadState.LOADING, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurfaceVariant), + ) { + Text(stringResource(when (historyLoadState) { + HistoryLoadState.LOADING -> R.string.chat_loading_older_messages + HistoryLoadState.FAILED -> R.string.chat_load_older_failed + else -> R.string.chat_load_older_messages + })) + } + } } } } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt index 81b28e6168..7fa2d64cb5 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt @@ -79,6 +79,7 @@ import com.openbitfun.mobile.core.feature.session.RemoteSessionUiState import com.openbitfun.mobile.core.feature.session.conversationRows import com.openbitfun.mobile.core.feature.session.modelOptions import com.openbitfun.mobile.core.feature.session.selectedModelOption +import com.openbitfun.mobile.core.feature.session.transcriptUnconfirmed import com.openbitfun.mobile.core.feature.workspace.RemoteFileDownloadUiState internal const val CONVERSATION_TEST_TAG: String = "conversation" @@ -444,6 +445,9 @@ private fun ConversationTimelineViewHost( ConversationTimelineView( rows = visibleRows, hasMoreMessages = state.hasMoreMessages, + transcriptUnconfirmed = state.timeline + ?.takeIf { it.sessionId == state.selectedSessionId } + ?.transcriptUnconfirmed() == true, historyLoadState = state.historyLoadState, onLoadOlder = { onIntent(RemoteSessionIntent.LoadOlderMessages) }, enabled = !state.busy, 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 c48c6b3859..c0edb5f862 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 @@ -438,6 +438,7 @@ OpenBitFun 用户 已认证 加载更早消息 + 正在同步 打开 OpenBitFun 授权 请使用当前版本的 OpenBitFun 设备二维码。 该设备已离线,或不属于当前 OpenBitFun 账户。 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 a85ac11907..ad4c6fc18e 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 @@ -80,6 +80,7 @@ The model service returned an unrecognized response. Could not reach the provider. Load earlier messages + Syncing Model Local custom model diff --git a/src/apps/mobile/android/app/src/test/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationScrollPolicyTest.kt b/src/apps/mobile/android/app/src/test/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationScrollPolicyTest.kt index 741e7af734..f09c8d08f6 100644 --- a/src/apps/mobile/android/app/src/test/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationScrollPolicyTest.kt +++ b/src/apps/mobile/android/app/src/test/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationScrollPolicyTest.kt @@ -57,10 +57,10 @@ class ConversationScrollPolicyTest { } @Test - fun lastItemIndexAccountsForTheLoadOlderHeader() { - org.junit.Assert.assertEquals(2, ConversationScrollPolicy.lastItemIndex(rowCount = 3, hasMoreMessages = false)) - org.junit.Assert.assertEquals(3, ConversationScrollPolicy.lastItemIndex(rowCount = 3, hasMoreMessages = true)) - org.junit.Assert.assertEquals(0, ConversationScrollPolicy.lastItemIndex(rowCount = 0, hasMoreMessages = false)) - org.junit.Assert.assertEquals(0, ConversationScrollPolicy.lastItemIndex(rowCount = 0, hasMoreMessages = true)) + fun lastItemIndexAccountsForTheLeadingHeader() { + org.junit.Assert.assertEquals(2, ConversationScrollPolicy.lastItemIndex(rowCount = 3, hasLeadingItem = false)) + org.junit.Assert.assertEquals(3, ConversationScrollPolicy.lastItemIndex(rowCount = 3, hasLeadingItem = true)) + org.junit.Assert.assertEquals(0, ConversationScrollPolicy.lastItemIndex(rowCount = 0, hasLeadingItem = false)) + org.junit.Assert.assertEquals(0, ConversationScrollPolicy.lastItemIndex(rowCount = 0, hasLeadingItem = true)) } } diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift index c910b75ef8..77ff3135ac 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift @@ -23,6 +23,21 @@ struct ChatTimelineView: View { // eager tail can repeatedly invalidate its own placement phases // during keyboard dismissal and long streamed replies. VStack(spacing: MobileDesignGeometry.messageSpacing) { + if model.surface == .remote && model.remoteTranscriptUnconfirmed { + // These rows are this device's stored copy, which stops + // wherever its last write stopped — inside the turn that + // was running when the app went away. Say the rest is on + // its way instead of letting a half-finished turn read as + // the session. + HStack(spacing: 7) { + ProgressView().controlSize(.small) + Text(model.localized("正在同步")) + .font(MobileDesignTypography.labelSmall.font) + } + .foregroundStyle(OpenBitFunTheme.muted) + .frame(maxWidth: .infinity, minHeight: 38) + .accessibilityIdentifier("timeline.syncing") + } if model.surface == .remote && model.remoteHasMoreMessages { Button { requestOlderHistoryPage() diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift index d92212fe5e..8d6a356cdf 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift @@ -403,6 +403,7 @@ extension MobileAppModel { remoteConversationOpenStartedAt = ProcessInfo.processInfo.systemUptime mobilePerformanceLog.info("Remote session open started generation=\(generation, privacy: .public)") remoteConversationLoading = false + remoteTranscriptUnconfirmed = false selectedSessionID = sessionID timelineRows = [] messages = [] @@ -421,6 +422,10 @@ extension MobileAppModel { guard let self, self.remoteConversationLoadGeneration == generation, self.remoteConversationOpeningSessionID == sessionID else { return } + // A pane that already shows this device's stored copy is not empty: it + // carries a "syncing" row while the host has not answered, and a + // skeleton over it would hide the only content there is. + guard self.timelineRows.isEmpty else { return } self.remoteConversationLoading = true // What ends this wait is the transcript arriving. One that never // arrives would otherwise leave the skeleton standing for the rest @@ -456,6 +461,7 @@ extension MobileAppModel { remoteConversationOpeningSessionID = nil remoteConversationOpenStartedAt = nil remoteConversationLoading = false + remoteTranscriptUnconfirmed = false } private func advancePendingDirectoryRemoteDraftIfReady() { @@ -1100,6 +1106,7 @@ extension MobileAppModel { } setPublishedIfChanged(\.modelOptions, to: projectedModelOptions) if acceptsTimeline, let timeline = ready.timeline { + setPublishedIfChanged(\.remoteTranscriptUnconfirmed, to: timeline.origin != .host) let projectedRows = MobileConversationRow.reconcile( timeline.conversationRows().map(Self.mapConversationRow), with: timelineRows) if timelineRows != projectedRows { @@ -1125,8 +1132,16 @@ extension MobileAppModel { ) } } - finishRemoteConversationOpenIfReady(timelineSessionID: timeline.sessionId) + // Only the host's own transcript settles the open. Rows restored from + // this device's copy can be shown (that is what makes a reopen + // instant) but they end inside the turn that ran when the app went + // away, so treating their arrival as the answer leaves that turn + // standing as the whole conversation until the host's rows land. + if timeline.origin == .host { + finishRemoteConversationOpenIfReady(timelineSessionID: timeline.sessionId) + } } else { + setPublishedIfChanged(\.remoteTranscriptUnconfirmed, to: false) setPublishedIfChanged(\.timelineRows, to: []) setPublishedIfChanged(\.messages, to: []) } diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift index 7729165c8c..e881a9e27f 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift @@ -22,6 +22,10 @@ final class MobileAppModel: ObservableObject { @Published var remoteHasMoreMessages = false @Published var remoteHistoryLoading = false @Published var remoteHistoryFailed = false + /// The rows on screen are this device's stored copy, not the host's + /// transcript: a reopened session shows them at once, and the host has not + /// answered for it yet. See `ChatTranscriptOrigin`. + @Published var remoteTranscriptUnconfirmed = false @Published var permissionMailbox: PermissionMailboxUiState? @Published var remoteConversationLoading = false @Published var remotePermissionMode = "ASK" diff --git a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineStore.kt b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineStore.kt index f16104d7ea..ab0539bae3 100644 --- a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineStore.kt +++ b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineStore.kt @@ -16,6 +16,25 @@ public enum class ChatSyncPhase { ERROR, } +/** + * Who the rows in a timeline belong to. + * + * A transcript is published twice on the way into a session: first from the + * copy this device wrote last time, then from the host once it answers. The two + * are not interchangeable — the stored copy stops wherever the last write + * stopped, which is inside the turn that was running when the app went away, and + * it is not the host's view of the session until the host says so. Consumers + * that would present a transcript as the session, or that gate a wait on "the + * transcript arrived", must read this: a wait ends on [HOST], not on rows. + */ +public enum class ChatTranscriptOrigin { + /** Rows restored from this device's stored copy; the host has not answered yet. */ + CACHE, + + /** Rows the host replayed or streamed for this session. */ + HOST, +} + public data class ChatTimelineState public constructor( public val sessionId: String, public val persistedMessages: List, @@ -26,6 +45,8 @@ public data class ChatTimelineState public constructor( public val modelCatalog: RemoteModelCatalog, public val selectedModelId: String, public val activeTurnAnchorId: String, + /** Who the rows above belong to; see [ChatTranscriptOrigin]. */ + public val origin: ChatTranscriptOrigin, ) public class ChatTimelineStore public constructor() { @@ -52,6 +73,17 @@ public class ChatTimelineStore public constructor() { state = state.copy(syncPhase = syncPhase) } + /** + * Records who the rows now held belong to. + * + * Set to [ChatTranscriptOrigin.HOST] where the host's transcript is applied, + * and back to [ChatTranscriptOrigin.CACHE] wherever this store is filled + * from this device's stored copy. Nothing else may move it. + */ + public fun setTranscriptOrigin(origin: ChatTranscriptOrigin) { + state = state.copy(origin = origin) + } + public fun setCursor(cursor: ChatSessionCursor) { state = state.copy(cursor = cursor.copy()) } @@ -383,6 +415,7 @@ public class ChatTimelineStore public constructor() { modelCatalog = RemoteModelCatalog(0, emptyList(), RemoteDefaultModels(), null), selectedModelId = "", activeTurnAnchorId = "", + origin = ChatTranscriptOrigin.CACHE, ) private fun emptyMessage(id: String, turnId: String?, status: String): ChatMessage = ChatMessage( diff --git a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineStoreTest.kt b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineStoreTest.kt index 1c08d3dc04..0c6650504d 100644 --- a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineStoreTest.kt +++ b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineStoreTest.kt @@ -777,6 +777,26 @@ class ChatTimelineStoreTest { assertFalse(store.snapshot().selectedModelId.isNotEmpty()) } + @Test + fun transcriptStartsAsThisDevicesCopyAndResetsBackToIt() { + val store = ChatTimelineStore() + store.reset("session-1") + store.setPersistedMessages(listOf(message("user-1", "user", "Hello"))) + assertEquals(ChatTranscriptOrigin.CACHE, store.snapshot().origin) + + store.setTranscriptOrigin(ChatTranscriptOrigin.HOST) + assertEquals(ChatTranscriptOrigin.HOST, store.snapshot().origin) + store.setPersistedMessages(listOf(message("user-1", "user", "Hello"), message("a-1", "assistant", "Hi"))) + assertEquals(ChatTranscriptOrigin.HOST, store.snapshot().origin) + + // A restarted stream drops everything derived from the previous replay, + // including the fact that the host had answered for it. + store.reset("session-1") + assertEquals(ChatTranscriptOrigin.CACHE, store.snapshot().origin) + store.reset() + assertEquals(ChatTranscriptOrigin.CACHE, store.snapshot().origin) + } + private fun message( id: String, role: String, 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 bb5b1c9075..93f0a1fefb 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 @@ -4,6 +4,7 @@ import com.openbitfun.mobile.core.domain.ChatMessage import com.openbitfun.mobile.core.domain.ChatTimelineItemType import com.openbitfun.mobile.core.domain.ChatTimelineProjector import com.openbitfun.mobile.core.domain.ChatTimelineState +import com.openbitfun.mobile.core.domain.ChatTranscriptOrigin import com.openbitfun.mobile.core.domain.ToolInputPolicy import com.openbitfun.mobile.core.domain.ToolQuestionPolicy import com.openbitfun.mobile.core.domain.ToolStatusPolicy @@ -213,6 +214,18 @@ public fun ChatTimelineState.conversationRows(): List = ) } +/** + * Whether this timeline is still the copy this device stored, rather than the + * host's answer for the session. + * + * The stored copy is worth showing at once, but it stops wherever the last write + * stopped — inside whatever turn was running when the app went away — so a wait + * for "the transcript" ends on the host's answer rather than on rows, and rows + * already on screen are labelled unconfirmed until it arrives. + */ +public fun ChatTimelineState.transcriptUnconfirmed(): Boolean = + origin != ChatTranscriptOrigin.HOST + /** * What to print for a message. * 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 6ca65a108e..1583e95f92 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 @@ -13,6 +13,7 @@ import com.openbitfun.mobile.core.persistence.PersistedWorkspaceIdentity import com.openbitfun.mobile.core.feature.relay.HostCatalogNotice import com.openbitfun.mobile.core.domain.ChatSyncPhase +import com.openbitfun.mobile.core.domain.ChatTranscriptOrigin import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonPrimitive @@ -944,6 +945,12 @@ public class RemoteSessionStore internal constructor( } } if (current == null) _connectionPhase.value = ConnectionPhase.CONNECTING + // Opening is not the host answering: the rows above are this device's + // stored copy, which stops wherever its last write stopped — inside the + // turn that was running when the app went away. Publishing them says + // "here is what this device has", and every consumer of the state has to + // be able to tell that apart from the host's own transcript. + timelineStore.setTranscriptOrigin(ChatTranscriptOrigin.CACHE) val operationToken = beginWork() _state.value = (_state.value as? RemoteSessionUiState.Ready)?.copy(busy = true) ?: current?.copy(busy = true) ?: RemoteSessionUiState.Loading @@ -1079,6 +1086,10 @@ public class RemoteSessionStore internal constructor( val active = messages.lastOrNull()?.takeIf { it.role == "assistant" && it.status == "streaming" } timelineStore.setPersistedMessages(if (active == null) messages else messages.dropLast(1)) timelineStore.setActiveTurn(active) + // These rows are the host's. A stream that restarted (`gap`) + // cleared the store, so this is also where a re-replayed + // session stops reading as this device's own copy. + timelineStore.setTranscriptOrigin(ChatTranscriptOrigin.HOST) val phase = when (messages.lastOrNull()?.status) { "streaming" -> ChatSyncPhase.STREAMING "failed" -> ChatSyncPhase.ERROR @@ -1091,6 +1102,10 @@ public class RemoteSessionStore internal constructor( { handleFailure(it, _state.value as? RemoteSessionUiState.Ready) }, { caughtUp = true + // The host has answered for this session, so a wait for its + // transcript can end. A session with no records has nothing + // to render and is still an answer. + timelineStore.setTranscriptOrigin(ChatTranscriptOrigin.HOST) if (!records.isEmpty) render() publishDurableTimeline() persistTranscript(sessionId, preserveOlder = sessionHistoryHasMore) @@ -1985,6 +2000,11 @@ public class RemoteSessionStore internal constructor( if (!persistenceEnabled || sessionId.isEmpty()) return val snapshot = timelineStore.snapshot() if (snapshot.sessionId != sessionId) return + // Only a transcript the host has confirmed is written back. A restored + // copy is this device's own text, and storing it again would let the + // next open read an artifact that claims to be the host's view of the + // session — including the unfinished turn that made the copy stale. + if (snapshot.origin != ChatTranscriptOrigin.HOST) return try { val p = persistence!! val persistedDeviceKey = deviceKey!! diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/ConversationModelPresentationTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/ConversationModelPresentationTest.kt index 72e0200320..95ece45cc3 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/ConversationModelPresentationTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/ConversationModelPresentationTest.kt @@ -3,6 +3,7 @@ package com.openbitfun.mobile.core.feature.session import com.openbitfun.mobile.core.domain.ChatSessionCursor import com.openbitfun.mobile.core.domain.ChatSyncPhase import com.openbitfun.mobile.core.domain.ChatTimelineState +import com.openbitfun.mobile.core.domain.ChatTranscriptOrigin import com.openbitfun.mobile.core.protocol.RemoteDefaultModels import com.openbitfun.mobile.core.protocol.RemoteModelCatalog import com.openbitfun.mobile.core.protocol.RemoteModelConfig @@ -111,4 +112,5 @@ private fun timeline( ), selectedModelId = selectedModelId, activeTurnAnchorId = "", + origin = ChatTranscriptOrigin.HOST, ) 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 a0a84238ad..288466d6a5 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 @@ -4,12 +4,14 @@ import com.openbitfun.mobile.core.domain.ChatMessage import com.openbitfun.mobile.core.domain.ChatSessionCursor import com.openbitfun.mobile.core.domain.ChatSyncPhase import com.openbitfun.mobile.core.domain.ChatTimelineState +import com.openbitfun.mobile.core.domain.ChatTranscriptOrigin import com.openbitfun.mobile.core.protocol.ChatMessageItemResponse import com.openbitfun.mobile.core.protocol.RemoteModelCatalog import com.openbitfun.mobile.core.protocol.RemoteToolStatusResponse import com.openbitfun.mobile.core.protocol.RelayJson import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue class ConversationPresentationTest { @@ -20,6 +22,15 @@ class ConversationPresentationTest { assertEquals(listOf(ConversationRowKind.EMPTY), rows.map { it.kind }) } + @Test + fun aTimelineThatIsStillThisDevicesCopyIsUnconfirmed() { + val stored = timeline().copy(origin = ChatTranscriptOrigin.CACHE) + val fromHost = timeline().copy(origin = ChatTranscriptOrigin.HOST) + + assertTrue(stored.transcriptUnconfirmed()) + assertFalse(fromHost.transcriptUnconfirmed()) + } + @Test fun anActiveTurnStaysBelowTheMessageThatStartedIt() { val persisted = listOf(message("user-1", "user", "First")) @@ -231,6 +242,7 @@ private fun timeline( cursor = ChatSessionCursor(0, 0, 0), modelCatalog = RemoteModelCatalog(version = 0), selectedModelId = "", + origin = ChatTranscriptOrigin.HOST, ) private fun message( 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 fdd482ece0..b7c4a69d93 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 @@ -1,5 +1,6 @@ package com.openbitfun.mobile.core.feature.session +import com.openbitfun.mobile.core.domain.ChatTranscriptOrigin import com.openbitfun.mobile.core.domain.RemoteSession import com.openbitfun.mobile.core.feature.connection.ConnectionPhase import com.openbitfun.mobile.core.persistence.ChatLocalStore @@ -459,6 +460,55 @@ class RemoteSessionPersistenceTest { store.stop() } + /** + * A reopened session immediately shows this device's stored copy, but that + * copy is not the host's transcript: it stops wherever its last write + * stopped, which is inside the turn that was running when the app went away. + * Presenting it as the session is what made a reopen show a lone user + * message as the whole conversation, so the state has to say where the rows + * came from and only stop saying "waiting" once the host has answered. + */ + @Test + fun aRestoredTranscriptIsThisDevicesCopyUntilTheHostAnswers() = runTest { + val stores = MemoryPersistence() + stores.transcripts.rows["device-a::server"] = listOf( + PersistedRemoteMessage(messageId = "m-user", sessionId = "server", role = "user", text = "do the thing"), + ) + val transport = PersistenceTransport().apply { + subscribeGate = CompletableDeferred() + initialRecords = listOf(richRecord("server", "t-1", 0, 1, "completed", "done")) + } + val store = RemoteSessionStore.create(this, transport, "device-a", stores.stores) + + store.dispatch(RemoteSessionIntent.Open("server")); runCurrent() + val restored = assertIs(store.state.value) + assertEquals(listOf("do the thing"), restored.timeline?.persistedMessages?.map { it.text }) + assertEquals(ChatTranscriptOrigin.CACHE, restored.timeline?.origin) + + transport.subscribeGate!!.complete(Unit); runCurrent() + val fromHost = assertIs(store.state.value) + assertEquals(ChatTranscriptOrigin.HOST, fromHost.timeline?.origin) + assertEquals("done", fromHost.timeline?.persistedMessages?.last()?.text) + store.stop() + } + + @Test + fun aRestoredTranscriptIsNotWrittenBackBeforeTheHostAnswers() = runTest { + val stores = MemoryPersistence() + val stored = listOf( + PersistedRemoteMessage(messageId = "m-user", sessionId = "server", role = "user", text = "do the thing"), + ) + stores.transcripts.rows["device-a::server"] = stored + val transport = PersistenceTransport().apply { subscribeGate = CompletableDeferred() } + val store = RemoteSessionStore.create(this, transport, "device-a", stores.stores) + + store.dispatch(RemoteSessionIntent.Open("server")); runCurrent() + + assertEquals(0, stores.transcripts.writes) + assertEquals(stored, stores.transcripts.rows["device-a::server"]) + store.stop() + } + @Test fun corruptedPayloadIsRetainedAsDegradedMessage() = runTest { val stores = MemoryPersistence() @@ -544,11 +594,14 @@ private class PersistenceTransport : RemoteCommandTransport, RemoteSessionStream var streamFailure: ((Throwable) -> Unit)? = null var caughtUp: (() -> Unit)? = null var subscriptions = 0 + /** Holds the host's stream open without answering, for the pre-answer window. */ + var subscribeGate: CompletableDeferred? = null 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 + subscribeGate?.await() initialRecords.forEach { emit(it) } onCaughtUp() records.collect { emit(it) } From a1d1799ee019066b00ee6ed8cf64ff401c040a39 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Tue, 22 Sep 2026 19:57:02 +0800 Subject: [PATCH 2/6] fix(core): stop reading a persisted InProgress turn as a live executor Relay reads now resolve a single requested turn through the derived turn catalog and fall back to the full transcript scan when the catalog cannot answer, logging which mode served the read. A turn whose persisted status is InProgress is no longer reported as running merely because this process does not own it: a loaded owner supplies runtime state, and for an unloaded session an exclusive writer lease proves no other process is executing that session. Only then does the observer projection mark abandoned turns Cancelled with finish_reason interrupted, and the persisted records keep their original status for recovery. Co-authored-by: OpenBitFun <318544290+bitfun-ai@users.noreply.github.com> --- src/crates/assembly/core/AGENTS.md | 5 + .../src/agentic/coordination/coordinator.rs | 193 +++++++++++++++++- .../core/src/agentic/persistence/manager.rs | 82 ++++++++ 3 files changed, 269 insertions(+), 11 deletions(-) diff --git a/src/crates/assembly/core/AGENTS.md b/src/crates/assembly/core/AGENTS.md index 68d57bbbdb..44861d11ad 100644 --- a/src/crates/assembly/core/AGENTS.md +++ b/src/crates/assembly/core/AGENTS.md @@ -212,6 +212,11 @@ or test-target layout. Workspace checks and product-wide tests are CI-backed and are not the default Core precheck. For documentation-only changes, run `git diff --check`. +For host-stream history reads and abandoned execution after a runtime restart: +`cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git --lib load_relay_session_turns_`. +The observer must preserve terminal history and another process's writer lease; +absence from one coordinator's memory alone never proves execution stopped. + Configuration persistence, account settings import, backup restore, legacy field/deletion compatibility, local-change notifications, and save/reload/model concurrency regressions have feature-free fixtures: diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 4519dee935..f922fefbee 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -8594,17 +8594,110 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await?; self.prepare_persisted_session_read_locked(storage, session_id) .await?; - let mut turns = self - .session_manager - .persistence_manager() - .load_visible_session_turns(storage, session_id) - .await?; - if let Some(turn_id) = turn_id { - turns.retain(|turn| turn.turn_id == turn_id); - if turns.is_empty() { - return Err(OpenBitFunError::NotFound(format!( - "Session turn unavailable: {turn_id}" - ))); + // Persisted InProgress is not proof of a live executor after restart. + // A loaded owner supplies runtime state; for an unloaded session an + // exclusive writer lease proves that no other process is executing it. + // Never infer interruption merely from absence in this process. + let loaded = self.session_manager.get_session(session_id); + let observer_lease = if loaded.is_none() { + match self + .session_manager + .persistence_manager() + .lock_session_writes(storage, session_id) + { + Ok(lease) => Some(lease), + Err(OpenBitFunError::SessionInUse { .. }) => None, + Err(error) => return Err(error), + } + } else { + None + }; + let execution_absent = loaded.as_ref().is_some_and(|session| { + matches!( + session.state, + SessionState::Idle | SessionState::Error { .. } + ) + }) || observer_lease.is_some(); + let (mut turns, read_mode) = if let Some(turn_id) = turn_id { + if let Some(turn) = self + .session_manager + .persistence_manager() + .load_visible_session_turn(storage, session_id, turn_id) + .await? + { + (vec![turn], "catalog") + } else { + let mut turns = self + .session_manager + .persistence_manager() + .load_visible_session_turns(storage, session_id) + .await?; + turns.retain(|turn| turn.turn_id == turn_id); + (turns, "full-fallback") + } + } else { + ( + self.session_manager + .persistence_manager() + .load_visible_session_turns(storage, session_id) + .await?, + "full", + ) + }; + debug!( + "Loaded relay session turns: session_id={} requested_turn_id={} read_mode={} turn_count={}", + session_id, + turn_id.unwrap_or(""), + read_mode, + turns.len() + ); + if turn_id.is_some() && turns.is_empty() { + return Err(OpenBitFunError::NotFound(format!( + "Session turn unavailable: {}", + turn_id.unwrap_or_default() + ))); + } + if execution_absent { + for turn in &mut turns { + if turn.status != TurnStatus::InProgress { + continue; + } + // Observer projection only: retain the original history and + // recovery checkpoints on disk. Terminal records stay intact. + turn.status = TurnStatus::Cancelled; + turn.finish_reason = Some("interrupted".to_string()); + turn.error = Some( + "Execution interrupted: the owning runtime is no longer running".to_string(), + ); + for round in &mut turn.model_rounds { + if matches!(round.status.as_str(), "inprogress" | "running" | "active") { + round.status = "cancelled".to_string(); + } + for item in &mut round.tool_items { + if item.tool_result.is_none() + && !matches!( + item.status.as_deref(), + Some( + "completed" + | "failed" + | "error" + | "cancelled" + | "rejected" + | "superseded" + | "retry_superseded" + ) + ) + { + item.status = Some("cancelled".to_string()); + } + } + for item in &mut round.text_items { + item.is_streaming = false; + } + for item in &mut round.thinking_items { + item.is_streaming = false; + } + } } } let context = self @@ -16999,6 +17092,84 @@ mod tests { .expect("clean up persisted test session"); } + #[tokio::test] + async fn load_relay_session_turns_marks_abandoned_execution_without_rewriting_history() { + let workspace = tempfile::tempdir().expect("workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(workspace.path()); + let (coordinator, manager) = test_persistent_coordinator(); + let session = manager + .create_session( + "Restart".into(), + "Standard".into(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().into_owned()), + ..Default::default() + }, + ) + .await + .unwrap(); + let id = &session.session_id; + let storage = manager.effective_session_storage_path(id).await.unwrap(); + let turn_id = manager + .start_dialog_turn( + id, + "Standard".into(), + "restart probe".into(), + Some("turn-restart-probe".into()), + None, + None, + ) + .await + .unwrap(); + let live = coordinator + .load_relay_session_turns(&storage, id, None) + .await + .unwrap(); + assert_eq!(live[0].status, TurnStatus::InProgress); + // Simulate the executor disappearing while the durable turn still says + // InProgress, as happens across process shutdown/restore. + manager.reset_session_state_if_processing(id, &turn_id); + let idle = coordinator + .load_relay_session_turns(&storage, id, None) + .await + .unwrap(); + assert_eq!(idle[0].status, TurnStatus::Cancelled); + manager.unload_session_from_memory(id).await.unwrap(); + // Another writer, even in the same process, forbids inferring death + // from this coordinator's empty in-memory map. + let owner = manager + .persistence_manager() + .lock_session_writes(&storage, id) + .unwrap(); + let observed = coordinator + .load_relay_session_turns(&storage, id, None) + .await + .unwrap(); + assert_eq!(observed[0].status, TurnStatus::InProgress); + drop(owner); + let orphan = coordinator + .load_relay_session_turns(&storage, id, None) + .await + .unwrap(); + assert_eq!(orphan[0].status, TurnStatus::Cancelled); + assert_eq!(orphan[0].finish_reason.as_deref(), Some("interrupted")); + let single = coordinator + .load_relay_session_turns(&storage, id, Some(&turn_id)) + .await + .unwrap(); + assert_eq!(single[0].status, TurnStatus::Cancelled); + let stored = manager + .persistence_manager() + .load_visible_session_turns(&storage, id) + .await + .unwrap(); + assert_eq!( + stored[0].status, + TurnStatus::InProgress, + "observer must retain persisted evidence" + ); + } + #[tokio::test] async fn load_relay_session_turns_reads_history_after_the_session_is_unloaded() { let workspace = tempfile::tempdir().expect("workspace"); diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index e49668e004..9c6282a664 100644 --- a/src/crates/assembly/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -3785,6 +3785,62 @@ impl PersistenceManager { .await } + /// Load one visible turn through the derived catalog when its entry is available. + /// + /// A missing or stale catalog deliberately returns `None` so callers can + /// preserve the full transcript fallback. Persisted turn files and the + /// revert boundary remain authoritative. + pub async fn load_visible_session_turn( + &self, + workspace_path: &Path, + session_id: &str, + turn_id: &str, + ) -> OpenBitFunResult> { + Self::validate_session_id(session_id)?; + let _session_write = match self.lock_session_write_operation(workspace_path, session_id) { + Ok(lock) => Some(lock), + Err(OpenBitFunError::SessionInUse { .. }) => None, + Err(error) => return Err(error), + }; + let boundary_turn = self + .load_session_revert_state(workspace_path, session_id) + .await? + .map(|state| state.boundary_turn); + let Some(catalog) = self + .read_session_turn_catalog_cache(workspace_path, session_id) + .await + else { + return Ok(None); + }; + let Some(entry) = catalog + .entries + .iter() + .find(|entry| entry.turn_id.as_deref() == Some(turn_id)) + else { + return Ok(None); + }; + if boundary_turn.is_some_and(|boundary| entry.storage_turn_index >= boundary) { + return Ok(None); + } + let Some(file) = self + .read_json_optional::(&self.turn_path( + workspace_path, + session_id, + entry.storage_turn_index, + )) + .await? + else { + return Ok(None); + }; + if file.turn.session_id != session_id + || file.turn.turn_id != turn_id + || file.turn.turn_index != entry.storage_turn_index + { + return Ok(None); + } + Ok(Some(file.turn)) + } + async fn project_visible_session_turns( &self, workspace_path: &Path, @@ -6296,6 +6352,32 @@ mod tests { .await .expect("staged revert should save"); + assert!(manager + .load_visible_session_turn(workspace.path(), &session_id, "turn-1") + .await + .expect("hidden lookup") + .is_none()); + // A corrupt unrelated file proves the indexed read never materializes it. + std::fs::write( + manager.turn_path(workspace.path(), &session_id, 1), + "invalid json", + ) + .expect("corrupt hidden fixture"); + assert_eq!( + manager + .load_visible_session_turn(workspace.path(), &session_id, "turn-0") + .await + .expect("indexed lookup") + .expect("visible turn") + .turn_id, + "turn-0" + ); + assert!(manager + .load_visible_session_turn(workspace.path(), &session_id, "unknown") + .await + .expect("missing lookup") + .is_none()); + let projected = manager .load_session_turn_catalog( workspace.path(), From c75878574848df480e372842d495fc8ff6012844 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Tue, 22 Sep 2026 19:57:11 +0800 Subject: [PATCH 3/6] fix(mobile): coalesce catalog invalidations and require a connected host to send HostCatalogObserver consumes the host's sessionsRevision and workspacesRevision hints with a coalescing floor instead of invalidating on every event, so a burst of host changes no longer costs one refresh each. RemoteSessionStore and RemoteWorkspaceStore keep session and workspace invalidation on separate paths and suspend refreshes while a turn is in flight. Composer send and the new stop action require a connected host instead of treating a reconnecting transport as reachable, because navigation may keep an offline session on screen and that is not authority to command its last-known execution state. Relay account requests log their timing. Co-authored-by: OpenBitFun <318544290+bitfun-ai@users.noreply.github.com> --- .../core/feature/relay/HostCatalogObserver.kt | 56 +++++++++- .../feature/session/ChatComposerPolicy.kt | 11 +- .../feature/session/RemoteSessionStore.kt | 31 +++++- .../feature/workspace/RemoteWorkspaceStore.kt | 19 +++- .../feature/relay/HostCatalogObserverTest.kt | 55 +++++++++- .../feature/session/ChatComposerPolicyTest.kt | 16 ++- .../feature/session/RemoteSessionStoreTest.kt | 101 +++++++++++++++++- .../core/transport/CloudAccountClient.kt | 29 ++++- 8 files changed, 294 insertions(+), 24 deletions(-) diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/relay/HostCatalogObserver.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/relay/HostCatalogObserver.kt index 7c34bfb10b..58faed1743 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/relay/HostCatalogObserver.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/relay/HostCatalogObserver.kt @@ -10,17 +10,37 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.delay import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.longOrNull internal sealed interface HostCatalogNotice { - data object Changed : HostCatalogNotice + data class Changed( + val sessionsRevision: Long? = null, + val workspacesRevision: Long? = null, + ) : HostCatalogNotice data object Failed : HostCatalogNotice } +/** + * Shortest spacing between two `Changed` notices handed to the catalog consumers. + * + * A running turn rewrites its own records, and every rewrite is one host catalog + * change, so the notices arrive for as long as the turn does. Each consumer + * answers a notice with relay round trips (`list_sessions` + `get_model_catalog`, + * `list_recent_workspaces` + `list_assistants`), and a pass takes about as long + * as the round trip it measures, so answering every notice keeps the one relayed + * channel busy around the clock — measured at ~2 commands per second during an active + * turn, competing with the user's own taps. A floor turns that into a bounded + * refresh rate; the panel is at most this stale, and the first notice after an + * idle period is still immediate. + */ +internal const val HOST_CATALOG_NOTICE_MIN_INTERVAL_MS: Long = 3_000L + /** * One account/target catalog stream, shared by workspace and session catalog * consumers. The catalog is read from the online host on demand; a host that @@ -28,13 +48,43 @@ internal sealed interface HostCatalogNotice { */ internal fun hostCatalogObserver(scope: CoroutineScope, source: RemoteSessionStreamTransport): Flow = channelFlow { var backoff = 1_000L + // Pacing lives on its own coroutine: the stream collector must keep draining + // the reader lane, and a delay in it would hold back the host's own pages. + val changed = Channel(Channel.CONFLATED) + var pending: HostCatalogNotice.Changed? = null + fun invalidate(notice: HostCatalogNotice.Changed) { + val previous = pending + pending = if (previous == HostCatalogNotice.Changed() || notice == HostCatalogNotice.Changed()) { + HostCatalogNotice.Changed() + } else { + HostCatalogNotice.Changed( + notice.sessionsRevision ?: previous?.sessionsRevision, + notice.workspacesRevision ?: previous?.workspacesRevision, + ) + } + changed.trySend(Unit) + } + launch { + while (true) { + changed.receive() + val notice = pending ?: continue + pending = null + send(notice) + delay(HOST_CATALOG_NOTICE_MIN_INTERVAL_MS) + } + } while (currentCoroutineContext().isActive) { var caughtUp = false try { source.subscribe(HOST_CATALOG_ID, { trySend(HostCatalogNotice.Failed) }, { - if (!caughtUp) { caughtUp = true; backoff = 1_000L; trySend(HostCatalogNotice.Changed) } + if (!caughtUp) { caughtUp = true; backoff = 1_000L; invalidate(HostCatalogNotice.Changed()) } }).collect { event -> - if (caughtUp && event["event"]?.jsonPrimitive?.contentOrNull in setOf("host-catalog-changed", STREAM_EVENT_RESUMED, STREAM_EVENT_GAP)) send(HostCatalogNotice.Changed) + if (caughtUp && event["event"]?.jsonPrimitive?.contentOrNull in setOf("host-catalog-changed", STREAM_EVENT_RESUMED, STREAM_EVENT_GAP)) { + val payload = event["payload"] as? kotlinx.serialization.json.JsonObject + val sessionsRevision = payload?.get("sessionsRevision")?.jsonPrimitive?.longOrNull + val workspacesRevision = payload?.get("workspacesRevision")?.jsonPrimitive?.longOrNull + invalidate(HostCatalogNotice.Changed(sessionsRevision, workspacesRevision)) + } } } catch (cancelled: CancellationException) { throw cancelled } catch (unsupported: HostStreamUnsupportedException) { send(HostCatalogNotice.Failed); awaitCancellation() } diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ChatComposerPolicy.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ChatComposerPolicy.kt index db26ae3002..2bdddb1f36 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ChatComposerPolicy.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ChatComposerPolicy.kt @@ -1,7 +1,6 @@ package com.openbitfun.mobile.core.feature.session import com.openbitfun.mobile.core.feature.connection.ConnectionPhase -import com.openbitfun.mobile.core.feature.connection.ConnectionStatusPresenter /** * When the composer's two primary actions are available. @@ -11,11 +10,13 @@ import com.openbitfun.mobile.core.feature.connection.ConnectionStatusPresenter * rejects the rest, and a rejection after the fact reads as a lost message. */ public object ChatComposerPolicy { + public fun canStop(streaming: Boolean, requiresRemoteConnection: Boolean, phase: ConnectionPhase): Boolean = + streaming && (!requiresRemoteConnection || phase == ConnectionPhase.CONNECTED) + /** * Send needs something to send, no command in flight, and — for a remote session — - * a reachable desktop. Reconnecting counts as reachable: a send during a - * blip queues rather than bouncing the user back to the connect screen, - * matching [ConnectionStatusPresenter.canReachSessions]. + * a connected desktop. Navigation may retain an offline session, but that + * does not authorize commands against its last-known execution state. */ public fun canSend( text: String, @@ -26,7 +27,7 @@ public object ChatComposerPolicy { ): Boolean { val hasContent = text.trim().isNotEmpty() || attachmentCount > 0 val remoteAvailable = - !requiresRemoteConnection || ConnectionStatusPresenter.canReachSessions(phase) + !requiresRemoteConnection || phase == ConnectionPhase.CONNECTED return hasContent && !busy && remoteAvailable } 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 1583e95f92..28297a511c 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 @@ -93,10 +93,24 @@ public class RemoteSessionStore internal constructor( private var catalogSubscription: Job? = null private var catalogRefresh: Job? = null private var catalogDirty = false + private var pendingSessionsRevision: Long? = null + private var appliedSessionsRevision: Long? = null internal fun bindCatalog(changes: kotlinx.coroutines.flow.Flow) { catalogSubscription?.cancel() catalogSubscription = scope.launch { changes.collect { notice -> - if (notice == HostCatalogNotice.Changed) refreshCatalog() else _connectionPhase.value = ConnectionPhase.RECONNECTING + when (notice) { + is HostCatalogNotice.Changed -> { + // A workspace-only invalidation must not make the session + // list and model catalog compete with the active turn. + val changed = (notice.sessionsRevision == null && notice.workspacesRevision == null) || + (notice.sessionsRevision != null && notice.sessionsRevision != appliedSessionsRevision) + if (changed) { + pendingSessionsRevision = notice.sessionsRevision + refreshCatalog() + } + } + HostCatalogNotice.Failed -> _connectionPhase.value = ConnectionPhase.RECONNECTING + } } } } private fun refreshCatalog() { @@ -106,6 +120,14 @@ public class RemoteSessionStore internal constructor( while (catalogDirty) { catalogDirty = false val before = _state.value as? RemoteSessionUiState.Ready ?: run { catalogDirty = true; return@launch } + // A running turn and the directory share the same relay. Keep the + // revision pending until the turn settles so catalog maintenance + // cannot delay transcript chunks or compete with history replay. + if (before.selectedSessionId != null && before.timeline?.activeTurn != null) { + catalogDirty = true + return@launch + } + val refreshingRevision = pendingSessionsRevision try { val page = listSessions(0, before.query, before.agentFilter, maxOf(PAGE_SIZE, before.sessions.size)) val latest = _state.value as? RemoteSessionUiState.Ready ?: return@launch @@ -114,6 +136,8 @@ public class RemoteSessionStore internal constructor( if (persistenceEnabled && before.query.isEmpty() && before.agentFilter == SessionAgentFilter.ALL) savePersistedSessions(page.sessions, page.hasMore) publishAuthorityReady(latest.copy(sessions = page.sessions, hasMore = page.hasMore)) refreshModelCatalog(invalidated = true) + appliedSessionsRevision = refreshingRevision ?: appliedSessionsRevision + if (pendingSessionsRevision == refreshingRevision) pendingSessionsRevision = null markConnected() } } catch (cancelled: CancellationException) { throw cancelled } @@ -1164,6 +1188,7 @@ public class RemoteSessionStore internal constructor( else -> ChatSyncPhase.IDLE }) if (caughtUp && !replayingHistory) publishDurableTimeline() + if (status != "running" && status != "streaming" && catalogDirty) refreshCatalog() } } } @@ -1596,7 +1621,7 @@ public class RemoteSessionStore internal constructor( val content = intent.content if (sessionId.isEmpty() || (content.trim().isEmpty() && intent.images.isNullOrEmpty())) return val current = _state.value as? RemoteSessionUiState.Ready ?: return - if (current.busy || current.selectedSessionId != sessionId) return + if (current.busy || current.selectedSessionId != sessionId || _connectionPhase.value != ConnectionPhase.CONNECTED) return val submittedDraftRevision = draftRevision val activeTurnId = current.timeline?.activeTurn?.turnId?.takeIf { it.isNotBlank() } val steering = plan == null && activeTurnId != null && "dialog_steer_v1" in hostCapabilities @@ -1768,7 +1793,7 @@ public class RemoteSessionStore internal constructor( private fun cancelTurn(intent: RemoteSessionIntent.CancelTurn) { val sessionId = intent.sessionId.trim() - if (sessionId.isEmpty()) return + if (sessionId.isEmpty() || _connectionPhase.value != ConnectionPhase.CONNECTED) return runAction(sessionId, RemoteCommand(cmd = "cancel_task", sessionId = sessionId, turnId = intent.turnId)) } diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStore.kt index 72481e0eda..f4b1382e6a 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStore.kt @@ -66,11 +66,23 @@ public class RemoteWorkspaceStore internal constructor( private var catalogSubscription: Job? = null private var catalogRefresh: Job? = null private var catalogDirty = false + private var pendingWorkspacesRevision: Long? = null + private var appliedWorkspacesRevision: Long? = null internal fun bindCatalog(changes: kotlinx.coroutines.flow.Flow) { catalogSubscription?.cancel() catalogSubscription = scope.launch { changes.collect { notice -> - if (notice == HostCatalogNotice.Changed) refreshCatalog() - else updateReady { it.copy(loadFailure = true) } + when (notice) { + is HostCatalogNotice.Changed -> { + // Session metadata changes do not alter the workspace picker. + val changed = (notice.sessionsRevision == null && notice.workspacesRevision == null) || + (notice.workspacesRevision != null && notice.workspacesRevision != appliedWorkspacesRevision) + if (changed) { + pendingWorkspacesRevision = notice.workspacesRevision + refreshCatalog() + } + } + HostCatalogNotice.Failed -> updateReady { it.copy(loadFailure = true) } + } } } } private fun refreshCatalog() { @@ -80,6 +92,7 @@ public class RemoteWorkspaceStore internal constructor( while (catalogDirty) { catalogDirty = false if (_state.value !is RemoteWorkspaceUiState.Ready) { catalogDirty = true; return@launch } + val refreshingRevision = pendingWorkspacesRevision try { val (recent, assistants) = coroutineScope { val a = async { transport.send(RemoteCommand(cmd = "list_recent_workspaces")) } @@ -90,6 +103,8 @@ public class RemoteWorkspaceStore internal constructor( RecentWorkspace(item.path.orEmpty(), item.name ?: basename(item.path.orEmpty()), item.lastOpened, item.workspaceKind.orEmpty(), item.remoteSshHost, item.remoteConnectionId, item.workspaceId) }, assistants = assistants.assistants.map { item -> WorkspaceAssistant(item.path, item.name, item.assistantId, item.workspaceId) }, catalog = recent.sidebarCatalog(assistants.assistants.map { item -> WorkspaceAssistant(item.path, item.name, item.assistantId, item.workspaceId) }), loadFailure = false) } + appliedWorkspacesRevision = refreshingRevision ?: appliedWorkspacesRevision + if (pendingWorkspacesRevision == refreshingRevision) pendingWorkspacesRevision = null } catch (cancelled: CancellationException) { throw cancelled } catch (_: Throwable) { updateReady { it.copy(loadFailure = true) } } } diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/relay/HostCatalogObserverTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/relay/HostCatalogObserverTest.kt index 17d7c1ebb7..952d0a1438 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/relay/HostCatalogObserverTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/relay/HostCatalogObserverTest.kt @@ -37,13 +37,64 @@ class HostCatalogObserverTest { val notices = mutableListOf() backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { hostCatalogObserver(backgroundScope, source).collect { notices += it } } runCurrent() - assertEquals(listOf(HostCatalogNotice.Changed), notices) + assertEquals(listOf(HostCatalogNotice.Changed()), notices) fun event(name: String) = buildJsonObject { put("session_id", HOST_CATALOG_ID); put("event", name); put("payload", JsonObject(emptyMap())) } events.emit(event("host-catalog-changed")); runCurrent() events.emit(event(STREAM_EVENT_GAP)); runCurrent() events.emit(event("session-record")); runCurrent() + assertEquals(1, notices.size) + advanceTimeBy(HOST_CATALOG_NOTICE_MIN_INTERVAL_MS + 1); runCurrent() + assertEquals(2, notices.size) + events.emit(event(STREAM_EVENT_RESUMED)); runCurrent() + assertEquals(2, notices.size) + advanceTimeBy(HOST_CATALOG_NOTICE_MIN_INTERVAL_MS + 1); runCurrent() assertEquals(3, notices.size) - assertTrue(notices.all { it == HostCatalogNotice.Changed }) + assertTrue(notices.all { it is HostCatalogNotice.Changed }) + } + + /** + * A running turn rewrites its records continuously, and every rewrite is one + * catalog change. Answering each one keeps the relayed channel busy with + * refreshes; the panel only has to be recent, so a burst costs one refresh. + */ + @Test fun aBurstOfChangesCostsOneRefreshPerInterval() = runTest { + val events = MutableSharedFlow(extraBufferCapacity = 64) + val source = object : RemoteSessionStreamTransport { + override suspend fun subscribe(sessionId: String, onError: (Throwable) -> Unit, onCaughtUp: () -> Unit): Flow = + events.onStart { onCaughtUp() } + } + val notices = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { hostCatalogObserver(backgroundScope, source).collect { notices += it } } + runCurrent() + assertEquals(1, notices.size) + fun event(name: String) = buildJsonObject { put("session_id", HOST_CATALOG_ID); put("event", name); put("payload", JsonObject(emptyMap())) } + repeat(20) { events.emit(event("host-catalog-changed")); advanceTimeBy(50); runCurrent() } + assertEquals(1, notices.size) + advanceTimeBy(HOST_CATALOG_NOTICE_MIN_INTERVAL_MS); runCurrent() + assertEquals(2, notices.size) + } + + @Test fun catalogRevisionPayloadSurvivesHintCoalescing() = runTest { + val events = MutableSharedFlow(extraBufferCapacity = 8) + val source = object : RemoteSessionStreamTransport { + override suspend fun subscribe(sessionId: String, onError: (Throwable) -> Unit, onCaughtUp: () -> Unit): Flow = + events.onStart { onCaughtUp() } + } + val notices = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + hostCatalogObserver(backgroundScope, source).collect { notices += it } + } + runCurrent() + assertEquals(HostCatalogNotice.Changed(), notices.single()) + events.emit(buildJsonObject { + put("session_id", HOST_CATALOG_ID) + put("event", "host-catalog-changed") + put("payload", buildJsonObject { put("sessionsRevision", 7); put("workspacesRevision", 11) }) + }) + runCurrent() + advanceTimeBy(HOST_CATALOG_NOTICE_MIN_INTERVAL_MS + 1) + runCurrent() + assertEquals(HostCatalogNotice.Changed(7, 11), notices.last()) } @Test fun anOlderHostIsReportedOnceAndNotPolledAgain() = runTest { diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/ChatComposerPolicyTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/ChatComposerPolicyTest.kt index 86573b27ed..7268908661 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/ChatComposerPolicyTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/ChatComposerPolicyTest.kt @@ -7,6 +7,15 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class ChatComposerPolicyTest { + @Test + fun retainedTurnCannotBeCancelledWhileHostIsUnavailable() { + for (phase in ConnectionPhase.entries) { + assertEquals(phase == ConnectionPhase.CONNECTED, ChatComposerPolicy.canStop(true, true, phase)) + } + assertFalse(ChatComposerPolicy.canStop(false, true, ConnectionPhase.CONNECTED)) + assertTrue(ChatComposerPolicy.canStop(true, false, ConnectionPhase.DISCONNECTED)) + } + @Test fun whitespaceIsNotContent() { assertFalse(canSend(text = " ")) @@ -25,10 +34,9 @@ class ChatComposerPolicyTest { } @Test - fun aBlipDoesNotBlockARemoteSend() { - // Reconnecting queues the message rather than refusing it — the same - // rule the sidebar uses to decide a session is still reachable. - assertTrue(canSend(text = "ship it", phase = ConnectionPhase.RECONNECTING)) + fun reconnectingRetainsTheDraftUntilTheHostIsConnected() { + // Keeping the conversation visible does not authorize an offline send. + assertFalse(canSend(text = "ship it", phase = ConnectionPhase.RECONNECTING)) assertFalse(canSend(text = "ship it", phase = ConnectionPhase.DISCONNECTED)) assertFalse(canSend(text = "ship it", phase = ConnectionPhase.FAILED)) } 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 75efb0e700..f4523c2874 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 @@ -546,9 +546,9 @@ class RemoteSessionStoreTest { val gate = CompletableDeferred() transport.commandGates["get_model_catalog"] = gate transport.commands.clear() - notices.emit(com.openbitfun.mobile.core.feature.relay.HostCatalogNotice.Changed) + notices.emit(com.openbitfun.mobile.core.feature.relay.HostCatalogNotice.Changed()) runCurrent() - notices.emit(com.openbitfun.mobile.core.feature.relay.HostCatalogNotice.Changed) + notices.emit(com.openbitfun.mobile.core.feature.relay.HostCatalogNotice.Changed()) runCurrent() gate.complete(Unit) runCurrent() @@ -560,6 +560,30 @@ class RemoteSessionStoreTest { store.stop() } + @Test + fun hostCatalogInvalidationWaitsForActiveTurnToSettle() = runTest { + val transport = FakeSessionTransport().apply { + initialEvents = listOf(richRecord("s-code", "active", 1, 1, "inprogress", "partial"), historyReady(false)) + } + val store = RemoteSessionStore(this, transport) + store.dispatch(RemoteSessionIntent.Open("s-code")); runCurrent() + assertNotNull(assertIs(store.state.value).timeline?.activeTurn) + val notices = MutableSharedFlow() + store.bindCatalog(notices); runCurrent() + transport.commands.clear() + notices.emit(com.openbitfun.mobile.core.feature.relay.HostCatalogNotice.Changed(7, null)); runCurrent() + assertTrue(transport.commands.none { it.cmd == "list_sessions" || it.cmd == "get_model_catalog" }) + + transport.streamEvents.emit(buildJsonObject { + put("session_id", "s-code"); put("event", "session-state") + put("payload", buildJsonObject { put("status", "completed") }) + }) + runCurrent() + assertTrue(transport.commands.any { it.cmd == "list_sessions" }) + assertTrue(transport.commands.any { it.cmd == "get_model_catalog" }) + store.stop() + } + @Test fun openingSessionLoadsItsModelWithoutBlockingReadyOrLeakingAcrossNavigation() = runTest { val transport = FakeSessionTransport() @@ -1890,6 +1914,79 @@ class RemoteSessionStoreTest { } + @Test + fun hostTerminalRecordSettlesRetainedRunningTurnWithoutLosingPartialOutput() = runTest { + for (status in listOf("cancelled", "error", "completed")) { + val transport = FakeSessionTransport() + val store = RemoteSessionStore(this, transport) + store.dispatch(RemoteSessionIntent.Open("s-code")); runCurrent() + transport.streamEvents.emit(richRecord("s-code", "t-1", 0, 1, "inprogress", "partial")); runCurrent() + assertNotNull(assertIs(store.state.value).timeline?.activeTurn) + store.dispatch(RemoteSessionIntent.UpdateDraft("continue after restart")) + transport.pingFailure = RelayFailure.NetworkUnreachable + store.dispatch(RemoteSessionIntent.SetForeground(true)); runCurrent() + assertEquals(ConnectionPhase.RECONNECTING, store.connectionPhase.value) + transport.commands.clear() + store.dispatch(RemoteSessionIntent.SendMessage("s-code", "continue after restart")); runCurrent() + assertTrue(transport.commands.none { it.cmd == "send_message" || it.cmd == "steer_turn" }) + assertEquals("continue after restart", assertIs(store.state.value).draft) + transport.pingFailure = null + transport.streamEvents.emit(richRecord("s-code", "t-1", 0, 2, status, "partial")); runCurrent() + val ready = assertIs(store.state.value) + assertNull(ready.timeline?.activeTurn) + assertEquals("partial", ready.timeline?.persistedMessages?.last()?.text) + assertFalse(ready.busy) + transport.commands.clear() + store.dispatch(RemoteSessionIntent.SendMessage("s-code", "continue after restart")); runCurrent() + assertEquals(1, transport.commands.count { it.cmd == "send_message" && it.content == "continue after restart" }) + assertTrue(transport.commands.none { it.cmd == "steer_turn" }) + store.stop() + } + } + + @Test + fun durableProjectionReplacesReorderedCorrectedAndDeletedItems() = runTest { + val transport = FakeSessionTransport() + val store = RemoteSessionStore(this, transport) + store.dispatch(RemoteSessionIntent.Open("s-code")); runCurrent() + fun event(revision: Long, id: String, type: String, order: Int, text: String): JsonObject { + val base = richRecord("s-code", "t-1", 0, revision, "inprogress", text) + val payload = base.getValue("payload").jsonObject.toMutableMap() + payload["id"] = JsonPrimitive("item/$id") + payload["item"] = buildJsonObject { + put("type", type) + put("data", buildJsonObject { + put("id", id); put("content", text); put("orderIndex", order) + if (type == "tool") { + put("toolName", "Read"); put("status", "completed") + put("toolCall", buildJsonObject { put("id", id); put("input", buildJsonObject {}) }) + } + }) + } + return JsonObject(base + ("payload" to JsonObject(payload))) + } + suspend fun emit(event: JsonObject) { transport.streamEvents.emit(event); runCurrent() } + fun active() = assertIs(store.state.value).timeline!!.activeTurn!! + emit(event(1, "reason", "thinking", 0, "old reasoning")) + emit(event(2, "answer", "text", 2, "old answer")) + emit(event(3, "tool", "tool", 1, "")) + repeat(30) { + emit(event(4L + it, "tool", "tool", 1, "")) + assertEquals(listOf("thinking", "tool", "text"), active().items!!.map { it.type }) + } + emit(event(40, "answer", "text", 2, "fixed")) + assertEquals("fixed", active().text) + emit(buildJsonObject { + put("session_id", "s-code"); put("event", "session-record") + put("payload", buildJsonObject { + put("sessionId", "s-code"); put("id", "item/reason"); put("revision", 41); put("deleted", true) + }) + }) + assertEquals(listOf("tool", "text"), active().items!!.map { it.type }) + assertTrue(active().thinking.isNullOrEmpty()) + store.stop() + } + @Test fun durableCompletionPreservesLoadedOlderRecords() = runTest { val transport = FakeSessionTransport().apply { 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 fe503a1b7d..b1525215dd 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 @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.mapNotNull @@ -51,9 +52,13 @@ import kotlinx.coroutines.CancellationException import kotlin.io.encoding.Base64 import kotlin.uuid.Uuid import kotlin.uuid.ExperimentalUuidApi +import kotlin.time.TimeSource public const val DEFAULT_CLOUD_RELAY_URL: String = "https://remote.openbitfun.com/v/1.0.2" +/** A relayed stream page slower than this is worth a breadcrumb; faster ones are not. */ +internal const val SLOW_STREAM_PAGE_MS: Long = 300L + /** Device kinds the relay accepts; mirrors `relay-service/src/db.rs::DEVICE_KINDS`. */ private const val DEVICE_KIND_DESKTOP = "desktop" @@ -307,9 +312,18 @@ public class CloudAccountClient internal constructor( val reads = object : HostStreamReads { override suspend fun read(after: Long?, before: Long?, epoch: Long?): StreamPageWire { check(realtime.value?.socket === socket) { "Account changed" } - return deviceRpc(relayUrl, session, target, + val startedAt = TimeSource.Monotonic.markNow() + val page = deviceRpc(relayUrl, session, target, RemoteCommand(cmd = "read_stream", streamId = sessionId, after = after, before = before, epoch = epoch, subscribe = true), StreamPageWire.serializer(), RELAY_DEFAULT_TIMEOUT_MS) + val elapsedMs = startedAt.elapsedNow().inWholeMilliseconds + // The opening page (`after == null`) is the one the user waits for, + // and a slow page is worth naming wherever it happens; a page per + // hint during a streaming turn is not, so it stays quiet. + if (after == null || elapsedMs >= SLOW_STREAM_PAGE_MS) { + log.info("stream page stream=${sessionId.take(24)} after=${after ?: -1} before=${before ?: -1} events=${page.events.size} has_more=${page.hasMore} ms=$elapsedMs") + } + return page } override suspend fun unsubscribe() { if (realtime.value?.socket !== socket) return @@ -317,12 +331,21 @@ public class CloudAccountClient internal constructor( CommandStatusResponse.serializer(), RELAY_DEFAULT_TIMEOUT_MS) } } + // Time from "the user opened this session" to "the host's rows are on + // screen, ready to render": every page read plus the reading side's own + // reduction of those pages. The store renders only after `onCaughtUp`, so + // the gap between the two counts is what the receiving device spends. + val openedAt = TimeSource.Monotonic.markNow() + var recordsSeen = 0 return hostStream(sessionId, target, hints, merge(socket.connections.drop(1), foregroundResumes), reads, olderRequests = historyRequests, onError = { error -> log.warn("host stream read failed stream=${sessionId.take(24)} type=${error::class.simpleName} failure=${(error as? CloudAccountException)?.failure} message=${error.message}") onError(error) - }, onCaughtUp = onCaughtUp).onCompletion { cause -> - log.info("host stream ended stream=${sessionId.take(24)} cause=${cause?.let { it::class.simpleName } ?: "none"}") + }, onCaughtUp = { + log.info("host stream caught up stream=${sessionId.take(24)} events=$recordsSeen elapsed_ms=${openedAt.elapsedNow().inWholeMilliseconds}") + onCaughtUp() + }).onEach { recordsSeen++ }.onCompletion { cause -> + log.info("host stream ended stream=${sessionId.take(24)} events=$recordsSeen cause=${cause?.let { it::class.simpleName } ?: "none"}") if (historyReaders[historyKey] === historyRequests) historyReaders.remove(historyKey) historyRequests.close() } From e238bcf686d5c818e02e083222b9a142bd78eaba Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Tue, 22 Sep 2026 19:57:11 +0800 Subject: [PATCH 4/6] fix(mobile): make the harmonyos transcript, catalog, and draft follow host authority Draft text now belongs to the submission that carried it: ComposerSubmission commits or rolls back against a revision, so a failed send keeps the text the user typed instead of clearing it or consuming a newer draft. Session and workspace rows resolve through keyed identity and a current-snapshot lookup instead of a stale local index, the sidebars, the picker, and the remote home read the same host catalog, and streaming markdown treats a rewritten or deleted block from the host as authoritative rather than discarding it as an out-of-order chunk. Adds the on-device catalog refresh fixture with tools/check-catalog-refresh.py and the composer-submit and streaming-markdown tool tests, and documents those entry points in the harmonyos guide. Co-authored-by: OpenBitFun <318544290+bitfun-ai@users.noreply.github.com> --- src/apps/mobile/harmonyos/AGENTS.md | 28 +++ .../main/ets/entryability/EntryAbility.ets | 6 +- .../src/main/ets/model/ComposerSubmission.ets | 5 + .../main/ets/pages/components/ComposerBar.ets | 4 +- .../ets/pages/components/ConversationView.ets | 2 +- .../ets/pages/components/RecentRemoteHome.ets | 24 ++- .../pages/components/RemoteSessionList.ets | 104 +++++++----- .../pages/components/SidebarDeviceGroup.ets | 93 ++++++---- .../components/SidebarWorkspacePicker.ets | 34 ++-- .../components/SidebarWorkspaceSection.ets | 17 +- .../components/StreamingMarkdownContent.ets | 19 +-- .../components/remote/RemoteSurfaceHost.ets | 4 +- .../pages/preview/CatalogRefreshPreview.ets | 82 +++++++++ .../preview/ComposerSubmissionPreview.ets | 63 +++++++ .../pages/preview/DurableTimelinePreview.ets | 94 ++++++++++ .../ets/pages/preview/MobileDesignGallery.ets | 11 +- .../runtime/AppRootRuntimeComposition.ets | 31 ++-- .../ets/pages/state/ConversationCoreState.ets | 38 +++++ .../viewmodel/DeviceDirectoryViewModel.ets | 1 + .../viewmodel/RemoteSessionViewModel.ets | 5 +- .../viewmodel/RemoteTranscriptController.ets | 6 +- .../viewmodel/RemoteWorkspaceViewModel.ets | 26 ++- .../main/ets/services/ChatComposerPolicy.ets | 6 +- .../ets/services/ChatSessionController.ets | 6 +- .../main/ets/services/ChatTimelineStore.ets | 9 +- .../main/ets/services/HostCatalogObserver.ets | 78 +++++++-- .../services/RemoteChatCommandController.ets | 20 ++- .../ets/services/RemoteSessionController.ets | 4 +- .../src/test/RemoteControllersUnit.test.ets | 6 +- .../test/TransportAndGeneralChatUnit.test.ets | 2 +- .../harmonyos/tools/check-catalog-refresh.py | 107 ++++++++++++ .../tools/tests/composer-submit.test.cjs | 98 +++++++++++ .../tools/tests/connection-health.test.cjs | 29 ++++ .../tools/tests/host-catalog.test.cjs | 59 ++++++- .../tools/tests/session-record.test.cjs | 160 ++++++++++++++++++ .../tools/tests/streaming-markdown.test.cjs | 49 ++++++ .../tests/workspace-refresh-race.test.cjs | 30 +++- 37 files changed, 1187 insertions(+), 173 deletions(-) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/model/ComposerSubmission.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/CatalogRefreshPreview.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ComposerSubmissionPreview.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/DurableTimelinePreview.ets create mode 100644 src/apps/mobile/harmonyos/tools/check-catalog-refresh.py create mode 100644 src/apps/mobile/harmonyos/tools/tests/composer-submit.test.cjs create mode 100644 src/apps/mobile/harmonyos/tools/tests/streaming-markdown.test.cjs diff --git a/src/apps/mobile/harmonyos/AGENTS.md b/src/apps/mobile/harmonyos/AGENTS.md index ea591459f2..29b3df897c 100644 --- a/src/apps/mobile/harmonyos/AGENTS.md +++ b/src/apps/mobile/harmonyos/AGENTS.md @@ -76,6 +76,34 @@ source scripts/ohos-env.sh "$HVIGORW" --mode module -p module=entry@default -p ohos.test.type=LocalTest test --no-daemon ``` +For workspace/session catalog rendering, install the debug HAP and run +`python3 tools/check-catalog-refresh.py --hdc "$HDC"` (add `--dark` for dark +mode). Run in compact and wide postures. The isolated fixture replaces objects +while preserving IDs and checks titles and click payloads in the sidebar, +recent sessions, time/project lists, and workspace picker. The script restores +the normal App even on assertion failure; it does not modify remote data. + +For composer submission timing and draft ownership, run +`node --test tools/tests/composer-submit.test.cjs`. The native preview scenario +`composer-submit` uses the real composer and command controller with a pending +fake RPC: send must clear the input before pressing **Acknowledge**, and a +**Next draft** entered while pending must survive acknowledgment. Exercise both +compact and wide layouts and return to normal `EntryAbility` afterward. + +For durable transcript projection, run +`node --test tools/tests/session-record.test.cjs tools/tests/host-stream.test.cjs tools/tests/streaming-markdown.test.cjs`. +The `durable-timeline` native preview uses the +production reducer, timeline store and rows. **Next replay** exercises late tool +insertion, 30 identical publications, text correction, deletion and completion; +block counts must be 2, 3, 3, 3, 2, 2, with one visible answer. Verify compact, +wide and live fold transitions, then return to normal `EntryAbility`. + +For host shutdown/restart handling, run +`node --test tools/tests/connection-health.test.cjs tools/tests/session-record.test.cjs tools/tests/host-stream.test.cjs`. +In `durable-timeline`, **Host offline** must retain content and disable Stop; +**Host returned** publishes an interrupted turn, preserving its output and +removing the running action. Test both postures and restore normal App afterward. + ## Visual reference fidelity - Before drawing a system glyph, text approximation, or new bitmap, search the existing HarmonyOS media resources and the approved desktop reference images. Reuse the established asset when one exists. diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets index 87e748dd6d..55ac397c8e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets @@ -13,7 +13,7 @@ export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { const scenarioId = want.parameters?.['openbitfunDesignPreview']; - if (scenarioId === 'device-selector' || scenarioId === 'device-selector-dark' || scenarioId === 'welcome-home' || scenarioId === 'connected-conversation' || + if (scenarioId === 'durable-timeline' || scenarioId === 'composer-submit' || scenarioId === 'catalog-refresh' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'device-selector' || scenarioId === 'device-selector-dark' || scenarioId === 'welcome-home' || scenarioId === 'connected-conversation' || scenarioId === 'streaming-dark' || scenarioId === 'reconnecting-wide' || scenarioId === 'narrow-multiline' || scenarioId === 'fold-context' || scenarioId === 'long-reading' || @@ -24,9 +24,9 @@ export default class EntryAbility extends UIAbility { AppStorage.setOrCreate('scenarioId', scenarioId); } try { - const colorMode = scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' + const colorMode = scenarioId === 'catalog-refresh-dark' || scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' ? ConfigurationConstant.ColorMode.COLOR_MODE_DARK - : scenarioId === 'device-selector' || scenarioId === 'connected-conversation' || scenarioId === 'reconnecting-wide' + : scenarioId === 'catalog-refresh' || scenarioId === 'device-selector' || scenarioId === 'connected-conversation' || scenarioId === 'reconnecting-wide' ? ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT : ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET; this.context.getApplicationContext().setColorMode(colorMode); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/model/ComposerSubmission.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/ComposerSubmission.ets new file mode 100644 index 0000000000..1842637e47 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/model/ComposerSubmission.ets @@ -0,0 +1,5 @@ +/** In-memory draft handoff. No transport or persistence is owned by this handle. */ +export interface ComposerSubmission { + commit: () => void; + rollback: () => void; +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets index d6ae0b8a28..5135aac2d0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets @@ -456,6 +456,8 @@ export struct ComposerBar { .stateEffect(false) .accessibilityText(RemoteI18n.t(ChatComposerPolicy.primaryActionAccessibilityKey( this.primaryAction(), this.isVoiceListening))) + .enabled(this.primaryAction() !== ComposerPrimaryAction.Stop || this.isVoiceListening || + ChatComposerPolicy.canStop(this.canStop, this.capabilities.requiresRemoteConnection, this.connectionState)) .onTouch((event: TouchEvent) => { if (!this.shouldShowActiveActionSurface()) { return; @@ -473,7 +475,7 @@ export struct ComposerBar { if (action === ComposerPrimaryAction.Stop) { if (this.isVoiceListening) { this.onVoiceInput(); - } else if (this.canStop) { + } else if (ChatComposerPolicy.canStop(this.canStop, this.capabilities.requiresRemoteConnection, this.connectionState)) { this.onStop(); } return; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets index 3307a1eebe..3181adb971 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets @@ -436,7 +436,7 @@ export struct ConversationView { () => this.onArchiveSession()) this.RemoteStyleMenuItem('remote_actions_settings', RemoteI18n.t('common.delete'), () => this.onDeleteSession()) - } else if (this.canStop) { + } else if (this.canStop && this.connectionState === 'connected') { Divider().strokeWidth(1).color(LINE).margin({ top: 8, bottom: 8 }) this.RemoteStyleMenuItem('remote_actions_settings', RemoteI18n.t('chat.stop'), () => this.onStop()) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RecentRemoteHome.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RecentRemoteHome.ets index c0b5f94ef5..f134013865 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RecentRemoteHome.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RecentRemoteHome.ets @@ -16,6 +16,18 @@ export struct RecentRemoteHome { @Event onBrowse: () => void = () => {}; @Event onConnect: () => void = () => {}; + // Keyed rows keep their identity; resolve their content from the latest snapshot. + @Computed + get sessionIndex(): Map { + const index = new Map(); + this.sessions.forEach((item: RemoteSession) => { index.set(`${item.deviceId || ''}:${item.id}`, item); }); + return index; + } + + private currentSession(item: RemoteSession): RemoteSession { + return this.sessionIndex.get(`${item.deviceId || ''}:${item.id}`) || item; + } + private sessionContext(session: RemoteSession): string { const workspace = session.workspaceName || (session.workspacePath || '').replace(/\/+$/, '').split('/').pop() || ''; return [this.desktopName, workspace].filter((value: string): boolean => value.length > 0).join(' · '); @@ -44,18 +56,18 @@ export struct RecentRemoteHome { Button(RemoteI18n.t('home.recent_all')).fontSize(MobileDesignTypography.labelSmall.size).fontColor(INK) .backgroundColor(TRANSPARENT).height(44).onClick(this.onBrowse) }.width('100%') - ForEach(this.sessions, (session: RemoteSession) => { + ForEach(this.sessions, (row: RemoteSession) => { Column({ space: 6 }) { - Text(session.title).fontSize(MobileDesignTypography.titleSmall.size).fontColor(INK).maxLines(2) + Text(this.currentSession(row).title).fontSize(MobileDesignTypography.titleSmall.size).fontColor(INK).maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }).width('100%') - Text(this.sessionContext(session)).fontSize(MobileDesignTypography.labelSmall.size).fontColor(MUTED).maxLines(1) + Text(this.sessionContext(this.currentSession(row))).fontSize(MobileDesignTypography.labelSmall.size).fontColor(MUTED).maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }).width('100%') }.width('100%').alignItems(HorizontalAlign.Start) .padding({ top: MobileDesignGeometry.recentHomeRowPadding, bottom: MobileDesignGeometry.recentHomeRowPadding }) - .onClick(() => { if (this.connected && !this.busy) this.onOpen(session); }) - .accessibilityGroup(true).accessibilityText(session.title) + .onClick(() => { if (this.connected) this.onOpen(this.currentSession(row)); }) + .accessibilityGroup(true).accessibilityText(this.currentSession(row).title) Divider().color(LINE) - }, (session: RemoteSession): string => session.id) + }, (session: RemoteSession): string => `${session.deviceId || ''}:${session.id}`) if (this.sessions.length === 0 && !this.busy) { Text(RemoteI18n.t('home.recent_empty')).fontSize(MobileDesignTypography.bodySmall.size).fontColor(MUTED) .padding({ top: 18, bottom: 18 }) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets index 52abd8085a..8dc6deba75 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets @@ -87,6 +87,30 @@ export struct RemoteSessionList { // none of which a selection touches. private readonly projectionCache: SessionListProjectionCache = new SessionListProjectionCache(); + // Keyed rows keep their identity; resolve their content from the latest snapshot. + @Computed + get sessionIndex(): Map { + const index = new Map(); + this.sessions.forEach((item: RemoteSession) => { index.set(`${item.deviceId || ''}:${item.id}`, item); }); + return index; + } + + private currentSession(item: RemoteSession): RemoteSession { + return this.sessionIndex.get(`${item.deviceId || ''}:${item.id}`) || item; + } + + // Keyed rows keep their identity; resolve their content from the latest snapshot. + @Computed + get projectIndex(): Map { + const index = new Map(); + this.projectEntries().forEach((item: RecentWorkspaceEntry) => { index.set(this.sectionKey(item), item); }); + return index; + } + + private currentProject(item: RecentWorkspaceEntry): RecentWorkspaceEntry { + return this.projectIndex.get(this.sectionKey(item)) || item; + } + @Monitor('isBusy', 'workspacePath') onWorkspaceContextChanged(): void { this.createMenuKey = ''; @@ -169,9 +193,9 @@ export struct RemoteSessionList { this.todayCollapsed = !this.todayCollapsed; }) if (!this.todayCollapsed) { - ForEach(this.sessionsForTimeBucket('today'), (item: RemoteSession) => { - this.SessionRow(item) - }, (item: RemoteSession) => item.id) + ForEach(this.sessionsForTimeBucket('today'), (row: RemoteSession) => { + this.SessionRow(row) + }, (item: RemoteSession): string => `${item.deviceId || ''}:${item.id}`) } } .width('100%') @@ -185,9 +209,9 @@ export struct RemoteSessionList { this.yesterdayCollapsed = !this.yesterdayCollapsed; }) if (!this.yesterdayCollapsed) { - ForEach(this.sessionsForTimeBucket('yesterday'), (item: RemoteSession) => { - this.SessionRow(item) - }, (item: RemoteSession) => item.id) + ForEach(this.sessionsForTimeBucket('yesterday'), (row: RemoteSession) => { + this.SessionRow(row) + }, (item: RemoteSession): string => `${item.deviceId || ''}:${item.id}`) } } .width('100%') @@ -201,9 +225,9 @@ export struct RemoteSessionList { this.earlierCollapsed = !this.earlierCollapsed; }) if (!this.earlierCollapsed) { - ForEach(this.sessionsForTimeBucket('earlier'), (item: RemoteSession) => { - this.SessionRow(item) - }, (item: RemoteSession) => item.id) + ForEach(this.sessionsForTimeBucket('earlier'), (row: RemoteSession) => { + this.SessionRow(row) + }, (item: RemoteSession): string => `${item.deviceId || ''}:${item.id}`) } } .width('100%') @@ -253,7 +277,7 @@ export struct RemoteSessionList { .width('100%') .height(44) .alignItems(VerticalAlign.Center) - ForEach(this.visibleProjectEntries(), (project: RecentWorkspaceEntry) => { + ForEach(this.visibleProjectEntries(), (row: RecentWorkspaceEntry) => { Column({ space: 2 }) { Row({ space: 10 }) { SymbolGlyph($r('sys.symbol.folder')) @@ -261,7 +285,7 @@ export struct RemoteSessionList { .fontColor([INK]) .width(24) .height(24) - Text(project.name || this.basename(project.path)) + Text(this.currentProject(row).name || this.basename(this.currentProject(row).path)) .fontSize(MobileDesignTypography.titleSmall.size) .fontColor(INK) .layoutWeight(1) @@ -272,23 +296,23 @@ export struct RemoteSessionList { // the left edge of any box forced wider than its natural, // half-width advance box. Stack({ alignContent: Alignment.Center }) { - SymbolGlyph(this.isWorkspaceCollapsed(this.sectionKey(project)) ? + SymbolGlyph(this.isWorkspaceCollapsed(this.sectionKey(this.currentProject(row))) ? $r('sys.symbol.chevron_right') : $r('sys.symbol.chevron_down')) .fontSize(14) .fontColor([MUTED]) } .width(18) .height(18) - if (project.path.length > 0) { + if (this.currentProject(row).path.length > 0) { SymbolGlyph($r('sys.symbol.square_and_pencil')) .fontSize(18) .fontColor([MUTED]) .width(22) .height(22) .opacity(0.52) - .bindPopup(this.createMenuKey.length > 0 && this.createMenuKey === this.sectionKey(project), { + .bindPopup(this.createMenuKey.length > 0 && this.createMenuKey === this.sectionKey(this.currentProject(row)), { builder: () => { - this.ProjectCreateMenu(project) + this.ProjectCreateMenu(row) }, placement: Placement.Top, popupColor: TRANSPARENT, @@ -303,7 +327,7 @@ export struct RemoteSessionList { } }) .onClick(() => { - const key = this.sectionKey(project); + const key = this.sectionKey(this.currentProject(row)); this.createMenuKey = this.createMenuKey === key ? '' : key; }) } @@ -311,10 +335,10 @@ export struct RemoteSessionList { .width('100%') .height(44) .onClick(() => { - this.toggleWorkspace(this.sectionKey(project)); + this.toggleWorkspace(this.sectionKey(this.currentProject(row))); }) - if (!this.isWorkspaceCollapsed(this.sectionKey(project))) { - this.projectPreview(project) + if (!this.isWorkspaceCollapsed(this.sectionKey(this.currentProject(row)))) { + this.projectPreview(row) } } .width('100%') @@ -337,19 +361,19 @@ export struct RemoteSessionList { } @Builder - private ProjectCreateMenu(project: RecentWorkspaceEntry) { + private ProjectCreateMenu(row: RecentWorkspaceEntry) { if (this.supportsHarnessProfiles) { HarnessProfileMenu({ includeCowork: true, onSelect: (agentType: string) => { this.createMenuKey = ''; - this.onCreateInWorkspace(project.path, agentType, project.remoteConnectionId, project.remoteSshHost, project.workspaceId); + this.onCreateInWorkspace(this.currentProject(row).path, agentType, this.currentProject(row).remoteConnectionId, this.currentProject(row).remoteSshHost, this.currentProject(row).workspaceId); } }) } else { Column({ space: 2 }) { - this.ProjectCreateMenuItem('Code', 'code', project) - this.ProjectCreateMenuItem('Cowork', 'Cowork', project) + this.ProjectCreateMenuItem('Code', 'code', row) + this.ProjectCreateMenuItem('Cowork', 'Cowork', row) } .width(150) .padding({ top: 8, bottom: 8 }) @@ -373,7 +397,7 @@ export struct RemoteSessionList { .padding({ left: 18, right: 18 }) .onClick(() => { this.createMenuKey = ''; - this.onCreateInWorkspace(project.path, agentType, project.remoteConnectionId, project.remoteSshHost, project.workspaceId); + this.onCreateInWorkspace(this.currentProject(project).path, agentType, this.currentProject(project).remoteConnectionId, this.currentProject(project).remoteSshHost, this.currentProject(project).workspaceId); }) } @@ -418,9 +442,9 @@ export struct RemoteSessionList { if (this.visibleChatSessions().length === 0) { this.EmptySessions() } else if (!this.chatsCollapsed) { - ForEach(this.visibleChatSessions().slice(0, this.chatVisibleCount), (item: RemoteSession) => { - this.SessionRow(item) - }) + ForEach(this.visibleChatSessions().slice(0, this.chatVisibleCount), (row: RemoteSession) => { + this.SessionRow(row) + }, (item: RemoteSession): string => `${item.deviceId || ''}:${item.id}`) if (this.chatVisibleCount < this.visibleChatSessions().length) { Text(RemoteI18n.f('remote.sessions.showMore', String(this.nextChatBatchSize()))) .fontSize(MobileDesignTypography.bodySmall.size) @@ -439,19 +463,19 @@ export struct RemoteSessionList { } @Builder - private projectPreview(project: RecentWorkspaceEntry) { - ForEach(this.visibleProjectSessions(project), (item: RemoteSession) => { - this.SessionRow(item, true) - }) - if (this.projectVisibleCount(project) < this.projectSessions(project).length) { - Text(RemoteI18n.f('remote.sessions.showMore', String(this.nextProjectBatchSize(project)))) + private projectPreview(row: RecentWorkspaceEntry) { + ForEach(this.visibleProjectSessions(this.currentProject(row)), (row: RemoteSession) => { + this.SessionRow(row, true) + }, (item: RemoteSession): string => `${item.deviceId || ''}:${item.id}`) + if (this.projectVisibleCount(this.currentProject(row)) < this.projectSessions(this.currentProject(row)).length) { + Text(RemoteI18n.f('remote.sessions.showMore', String(this.nextProjectBatchSize(this.currentProject(row))))) .fontSize(MobileDesignTypography.titleSmall.size) .fontColor(MUTED) .height(44) .width('100%') .textAlign(TextAlign.Center) .onClick(() => { - this.projectVisibleSteps = this.projectVisibleSteps.concat([this.sectionKey(project)]); + this.projectVisibleSteps = this.projectVisibleSteps.concat([this.sectionKey(this.currentProject(row))]); }) } } @@ -565,18 +589,18 @@ export struct RemoteSessionList { } @Builder - private SessionRow(item: RemoteSession, nested: boolean = false) { + private SessionRow(row: RemoteSession, nested: boolean = false) { RemoteSessionRow({ - session: item, - metadata: this.metadataText(item), + session: this.currentSession(row), + metadata: this.metadataText(this.currentSession(row)), nested, - selected: this.isSessionSelected(item.id), + selected: this.isSessionSelected(this.currentSession(row).id), busy: this.isBusy, actionPresentation: this.actionPresentation, - showActionPopover: this.activeActionSessionId === item.id, + showActionPopover: this.activeActionSessionId === this.currentSession(row).id, actionsPopup: () => { this.SessionActionPopover() }, onPressStateChange: (pressed: boolean) => { - this.optimisticSelectedSessionId = pressed ? item.id : ''; + this.optimisticSelectedSessionId = pressed ? this.currentSession(row).id : ''; }, onOpen: (session: RemoteSession) => { this.optimisticSelectedSessionId = session.id; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarDeviceGroup.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarDeviceGroup.ets index 31961aace7..6693a02eac 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarDeviceGroup.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarDeviceGroup.ets @@ -2,6 +2,7 @@ import { remoteWorkspaceIdentityMatches, remoteWorkspaceKey, remoteSessionBelong import { WorkspaceToolsIntent } from '../state/WorkspaceToolsState'; import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { RecentWorkspaceEntry, RemoteSession, RemoteWorkspaceIdentity } from '../../model/RemoteModels'; +import { RemoteUiState } from '../../services/RemoteUiState'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { DeviceDirectoryEntry, @@ -60,6 +61,18 @@ struct SidebarWorkspaceGroup { @Local visibleSessionCount: number = SidebarDirectoryPreviewPolicy.PREVIEW_COUNT; @Local showCreateMenu: boolean = false; + // Keyed rows keep their identity; resolve their content from the latest snapshot. + @Computed + get sessionIndex(): Map { + const index = new Map(); + this.sessions.forEach((item: RemoteSession) => { index.set(`${item.deviceId || ''}:${item.id}`, item); }); + return index; + } + + private currentSession(item: RemoteSession): RemoteSession { + return this.sessionIndex.get(`${item.deviceId || ''}:${item.id}`) || item; + } + private workspaceIdOrUndefined(): string | undefined { return this.workspaceId.length > 0 ? this.workspaceId : undefined; } @@ -73,8 +86,8 @@ struct SidebarWorkspaceGroup { } else if (this.loadStatus === 'failed') { this.SessionRetryRow() } - ForEach(this.visibleSessions(), (item: RemoteSession) => { - this.SessionRow(item) + ForEach(this.visibleSessions(), (row: RemoteSession) => { + this.SessionRow(row) }, (item: RemoteSession): string => `${item.deviceId || this.deviceId}:${item.id}`) if (this.hiddenSessionCount() > 0) { this.MoreSessionsRow() @@ -213,15 +226,15 @@ struct SidebarWorkspaceGroup { } @Builder - private SessionRow(item: RemoteSession) { + private SessionRow(row: RemoteSession) { Row({ space: 8 }) { Stack({ alignContent: Alignment.BottomStart }) { - SymbolGlyph(this.sessionGlyph(item)) + SymbolGlyph(this.sessionGlyph(this.currentSession(row))) .fontSize(17) .fontColor([SIDEBAR_MUTED]) .width(19) .height(19) - if (this.activeDevice && item.id === this.runningSessionId) { + if (this.activeDevice && this.currentSession(row).id === this.runningSessionId) { Text('') .width(7) .height(7) @@ -231,9 +244,9 @@ struct SidebarWorkspaceGroup { } .width(19) .height(19) - Text(item.title || RemoteI18n.t('sidebar.untitled')) + Text(this.currentSession(row).title || RemoteI18n.t('sidebar.untitled')) .fontSize(MobileDesignTypography.bodySmall.size) - .fontWeight(item.id === this.selectedSessionId ? FontWeight.Bold : FontWeight.Regular) + .fontWeight(this.currentSession(row).id === this.selectedSessionId ? FontWeight.Bold : FontWeight.Regular) .fontColor(SIDEBAR_INK) .layoutWeight(1) .maxLines(1) @@ -244,13 +257,13 @@ struct SidebarWorkspaceGroup { .height(44) .padding({ left: this.bodyIndent + 34, right: 10 }) .alignItems(VerticalAlign.Center) - .backgroundColor(item.id === this.selectedSessionId ? SIDEBAR_SELECTION : TRANSPARENT) + .backgroundColor(this.currentSession(row).id === this.selectedSessionId ? SIDEBAR_SELECTION : TRANSPARENT) .borderRadius(10) .onClick(() => { - this.onOpenSession(item); + this.onOpenSession(this.currentSession(row)); }) .gesture(LongPressGesture({ repeat: false }).onAction(() => { - this.onSessionActions(item); + this.onSessionActions(this.currentSession(row)); })) } @@ -333,7 +346,6 @@ export struct SidebarDeviceGroup { @Param device: DeviceDirectoryEntry = new DeviceDirectoryEntry(); @Param directoryState: DeviceDirectoryState = new DeviceDirectoryState(); @Param sessions: RemoteSession[] = []; - @Param recentWorkspaces: RecentWorkspaceEntry[] = []; @Param workspaceName: string = ''; @Param workspacePath: string = ''; @Param workspaceConnectionId: string = ''; @@ -358,6 +370,23 @@ export struct SidebarDeviceGroup { @Local visibleWorkspaceCount: number = SidebarDirectoryPreviewPolicy.PREVIEW_COUNT; private readonly projectionCache: SessionListProjectionCache = new SessionListProjectionCache(); + // Keyed rows keep their identity; resolve their content from the latest snapshot. + @Computed + get workspaceIndex(): Map { + const index = new Map(); + this.device.workspaces.forEach((item: RecentWorkspaceEntry) => { index.set(remoteWorkspaceKey(item), item); }); + return index; + } + + private currentWorkspace(item: RecentWorkspaceEntry): RecentWorkspaceEntry { + return this.workspaceIndex.get(remoteWorkspaceKey(item)) || item; + } + + @Computed + get deviceSessions(): RemoteSession[] { + return this.isActive ? RemoteUiState.mergeSessions(this.sessions, this.device.sessions) : this.device.sessions; + } + build() { Column() { if (this.device.status === 'failed') { @@ -371,39 +400,38 @@ export struct SidebarDeviceGroup { this.LoadingRow() } } else { - ForEach(this.visibleWorkspaceEntries(), (entry: RecentWorkspaceEntry) => { + ForEach(this.visibleWorkspaceEntries(), (row: RecentWorkspaceEntry) => { SidebarWorkspaceGroup({ - connectionId: entry.remoteConnectionId || '', - sshHost: entry.remoteSshHost || '', - workspaceId: entry.workspaceId || '', + connectionId: this.currentWorkspace(row).remoteConnectionId || '', + sshHost: this.currentWorkspace(row).remoteSshHost || '', + workspaceId: this.currentWorkspace(row).workspaceId || '', onWorkspaceTools: this.onWorkspaceTools, deviceId: this.device.deviceId, - path: entry.path, - name: entry.remoteSshHost?.trim() ? `${entry.name} · ${entry.remoteSshHost.trim()}` : entry.name, - sessions: this.sessionsFor(entry), + path: this.currentWorkspace(row).path, + name: this.currentWorkspace(row).remoteSshHost?.trim() ? `${this.currentWorkspace(row).name} · ${(this.currentWorkspace(row).remoteSshHost || '').trim()}` : this.currentWorkspace(row).name, + sessions: this.sessionsFor(this.currentWorkspace(row)), selectedSessionId: this.selectedSessionId, runningSessionId: this.runningSessionId, - currentWorkspace: this.isCurrentWorkspace(entry), + currentWorkspace: this.isCurrentWorkspace(this.currentWorkspace(row)), activeDevice: this.isActive, supportsHarnessProfiles: this.supportsHarnessProfiles, bodyIndent: 10, expanded: this.directoryState.workspaceExpanded( - this.device.deviceId, entry.path, entry.remoteConnectionId, entry.remoteSshHost, entry.workspaceId), + this.device.deviceId, this.currentWorkspace(row).path, this.currentWorkspace(row).remoteConnectionId, this.currentWorkspace(row).remoteSshHost, this.currentWorkspace(row).workspaceId), loadStatus: this.directoryState.workspaceStatus( - this.device.deviceId, entry.path, entry.remoteConnectionId, entry.remoteSshHost, entry.workspaceId), + this.device.deviceId, this.currentWorkspace(row).path, this.currentWorkspace(row).remoteConnectionId, this.currentWorkspace(row).remoteSshHost, this.currentWorkspace(row).workspaceId), hasLoadedSessions: this.directoryState.workspaceHasLoadedSessions( - this.device.deviceId, entry.path, entry.remoteConnectionId, entry.remoteSshHost, entry.workspaceId), + this.device.deviceId, this.currentWorkspace(row).path, this.currentWorkspace(row).remoteConnectionId, this.currentWorkspace(row).remoteSshHost, this.currentWorkspace(row).workspaceId), onOpenWorkspace: this.onOpenWorkspace, onExpandWorkspace: (path: string): Promise => - this.onExpandWorkspace(path, entry.remoteConnectionId, entry.remoteSshHost, entry.workspaceId), + this.onExpandWorkspace(path, this.currentWorkspace(row).remoteConnectionId, this.currentWorkspace(row).remoteSshHost, this.currentWorkspace(row).workspaceId), onExpandedChange: (path: string, expanded: boolean): void => - this.onWorkspaceExpandedChange(path, expanded, entry.remoteConnectionId, entry.remoteSshHost, entry.workspaceId), + this.onWorkspaceExpandedChange(path, expanded, this.currentWorkspace(row).remoteConnectionId, this.currentWorkspace(row).remoteSshHost, this.currentWorkspace(row).workspaceId), onCreateInWorkspace: this.onCreateInWorkspace, onOpenSession: this.onOpenSession, onSessionActions: this.onSessionActions }) - }, (entry: RecentWorkspaceEntry): string => - `${this.device.deviceId}:${remoteWorkspaceKey(entry)}`) + }, (entry: RecentWorkspaceEntry): string => `${this.device.deviceId}:${remoteWorkspaceKey(entry)}`) if (this.hiddenWorkspaceCount() > 0) { this.MoreWorkspacesRow() } @@ -487,7 +515,7 @@ export struct SidebarDeviceGroup { private projection(): SessionListProjection { const inputs: SessionListInputs = { - sessions: this.sessions, + sessions: this.deviceSessions, query: this.query, sortMode: 'project', workspaceName: this.workspaceName, @@ -496,7 +524,7 @@ export struct SidebarDeviceGroup { workspaceConnectionId: this.workspaceConnectionId, workspaceSshHost: this.workspaceSshHost, workspaceKind: this.workspaceKind, - recentWorkspaces: this.recentWorkspaces, + recentWorkspaces: this.device.workspaces, workspaceFilter: '', agentFilter: '', statusFilter: '', @@ -510,7 +538,10 @@ export struct SidebarDeviceGroup { private workspaceEntries(): RecentWorkspaceEntry[] { // Device catalog membership is authoritative, even when empty. Selection // and historical sessions cannot reopen a workspace in the sidebar. - const entries = this.recentWorkspaces.slice(); + // Read the traced catalog at the rendering boundary. A separate array + // parameter forwarded through the keyed parent builder can still contain + // the handshake's empty snapshot when this device becomes ready. + const entries = this.device.workspaces.slice(); const query = this.query.trim().toLowerCase(); if (query.length === 0) { return entries; @@ -538,7 +569,7 @@ export struct SidebarDeviceGroup { private sessionsFor(entry: RecentWorkspaceEntry): RemoteSession[] { return this.projection().filtered.filter((item: RemoteSession): boolean => - remoteSessionBelongsToWorkspace(item, entry, this.workspaceEntries())); + remoteSessionBelongsToWorkspace(item, entry, this.device.workspaces)); } private isCurrentWorkspace(entry: RecentWorkspaceEntry): boolean { @@ -551,7 +582,7 @@ export struct SidebarDeviceGroup { remoteConnectionId: this.workspaceConnectionId.length > 0 ? this.workspaceConnectionId : undefined, remoteSshHost: this.workspaceSshHost.length > 0 ? this.workspaceSshHost : undefined }; - return remoteWorkspaceIdentityMatches(entry, current, this.recentWorkspaces); + return remoteWorkspaceIdentityMatches(entry, current, this.device.workspaces); } private basename(path: string): string { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets index 9375b78fde..d11d72dc6d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets @@ -30,6 +30,18 @@ export struct SidebarWorkspacePicker { @Event onSelectWorkspace: (path: string, connectionId?: string, sshHost?: string, workspaceId?: string) => void = (_path: string, _connectionId?: string) => {}; + // Keyed rows keep their identity; resolve their content from the latest snapshot. + @Computed + get workspaceIndex(): Map { + const index = new Map(); + this.workspaces.forEach((item: RecentWorkspaceEntry) => { index.set(remoteWorkspaceKey(item), item); }); + return index; + } + + private currentWorkspace(item: RecentWorkspaceEntry): RecentWorkspaceEntry { + return this.workspaceIndex.get(remoteWorkspaceKey(item)) || item; + } + private isSelected(workspace: RecentWorkspaceEntry): boolean { if (this.selectedWorkspacePath.length === 0 && this.selectedWorkspaceId.length === 0) { return false; @@ -72,8 +84,8 @@ export struct SidebarWorkspacePicker { } else { Scroll() { Column({ space: 4 }) { - ForEach(this.workspaces, (workspace: RecentWorkspaceEntry) => { - this.WorkspaceRow(workspace) + ForEach(this.workspaces, (row: RecentWorkspaceEntry) => { + this.WorkspaceRow(row) }, (workspace: RecentWorkspaceEntry): string => remoteWorkspaceKey(workspace)) } .width('100%') @@ -149,7 +161,7 @@ export struct SidebarWorkspacePicker { } @Builder - private WorkspaceRow(workspace: RecentWorkspaceEntry) { + private WorkspaceRow(row: RecentWorkspaceEntry) { Row({ space: 12 }) { Stack({ alignContent: Alignment.Center }) { SymbolGlyph($r('sys.symbol.folder')) @@ -161,14 +173,14 @@ export struct SidebarWorkspacePicker { .borderRadius(10) .backgroundColor(CARD) Column({ space: 3 }) { - Text((workspace.name || this.basename(workspace.path)) + (workspace.remoteSshHost?.trim() ? ` · ${workspace.remoteSshHost.trim()}` : '')) + Text((this.currentWorkspace(row).name || this.basename(this.currentWorkspace(row).path)) + (this.currentWorkspace(row).remoteSshHost?.trim() ? ` · ${(this.currentWorkspace(row).remoteSshHost || '').trim()}` : '')) .width('100%') .fontSize(MobileDesignTypography.bodyLarge.size) - .fontWeight(this.isSelected(workspace) ? FontWeight.Medium : FontWeight.Regular) + .fontWeight(this.isSelected(this.currentWorkspace(row)) ? FontWeight.Medium : FontWeight.Regular) .fontColor(INK) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) - Text(workspace.path) + Text(this.currentWorkspace(row).path) .width('100%') .fontSize(MobileDesignTypography.labelSmall.size) .fontColor(MUTED) @@ -178,10 +190,10 @@ export struct SidebarWorkspacePicker { .layoutWeight(1) .alignItems(HorizontalAlign.Start) Stack({ alignContent: Alignment.Center }) { - SymbolGlyph(this.isSelected(workspace) ? + SymbolGlyph(this.isSelected(this.currentWorkspace(row)) ? $r('sys.symbol.checkmark') : $r('sys.symbol.chevron_right')) - .fontSize(this.isSelected(workspace) ? 17 : 15) - .fontColor([this.isSelected(workspace) ? INK : MUTED]) + .fontSize(this.isSelected(this.currentWorkspace(row)) ? 17 : 15) + .fontColor([this.isSelected(this.currentWorkspace(row)) ? INK : MUTED]) } .width(40) .height(40) @@ -190,8 +202,8 @@ export struct SidebarWorkspacePicker { .height(68) .padding({ left: 10, right: 8 }) .borderRadius(14) - .backgroundColor(this.isSelected(workspace) ? SOFT : PAGE_BG) - .onClick(() => this.onSelectWorkspace(workspace.path, workspace.remoteConnectionId, workspace.remoteSshHost, workspace.workspaceId)) + .backgroundColor(this.isSelected(this.currentWorkspace(row)) ? SOFT : PAGE_BG) + .onClick(() => this.onSelectWorkspace(this.currentWorkspace(row).path, this.currentWorkspace(row).remoteConnectionId, this.currentWorkspace(row).remoteSshHost, this.currentWorkspace(row).workspaceId)) } @Builder diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets index b6a01689ec..85ecdb1e90 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets @@ -29,7 +29,6 @@ import { SidebarDeviceGroup } from './SidebarDeviceGroup'; import { SidebarDeviceIcon } from './SidebarNavigationIcons'; import { SidebarWorkspacePicker } from './SidebarWorkspacePicker'; import { ConnectionStatusPresenter } from '../../services/ConnectionStatusPresenter'; -import { RemoteUiState } from '../../services/RemoteUiState'; /** * Flat device selector followed by the selected device's workspace tree. @@ -107,11 +106,12 @@ export struct SidebarWorkspaceSection { this.MoreDevicesRow() } // The workspace tree owns projection caches and local disclosure state. - // Key the single visible tree by device so switching the flat selector - // cannot reuse the outgoing device's component instance and projection. + // Include the projection source: a transient control target and its + // account directory entry are different observed objects, even when + // they identify the same device. ForEach(this.selectedEntries(), (entry: DeviceDirectoryEntry) => { this.WorkspaceSection(entry) - }, (entry: DeviceDirectoryEntry): string => entry.deviceId) + }, (entry: DeviceDirectoryEntry): string => this.deviceKey(entry)) } } .width('100%') @@ -263,8 +263,7 @@ export struct SidebarWorkspaceSection { onWorkspaceTools: this.onWorkspaceTools, device: entry, directoryState: this.directoryState, - sessions: this.sessionsForDevice(entry), - recentWorkspaces: this.workspacesForDevice(entry), + sessions: this.usesLiveDirectory(entry) ? this.sessions : [], workspaceName: this.liveWorkspaceName(entry), workspacePath: this.liveWorkspacePath(entry), workspaceConnectionId: this.isActiveDevice(entry.deviceId) ? this.workspaceConnectionId : '', @@ -439,12 +438,6 @@ export struct SidebarWorkspaceSection { private liveWorkspaceKind(entry: DeviceDirectoryEntry): string { return this.usesLiveDirectory(entry) ? this.workspaceKind : 'normal'; } - - private sessionsForDevice(entry: DeviceDirectoryEntry): RemoteSession[] { - return this.usesLiveDirectory(entry) ? - RemoteUiState.mergeSessions(this.sessions, entry.sessions) : entry.sessions; - } - private workspacesForDevice(entry: DeviceDirectoryEntry): RecentWorkspaceEntry[] { return entry.workspaces; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StreamingMarkdownContent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StreamingMarkdownContent.ets index e2216b9d6a..9d0401ee03 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StreamingMarkdownContent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StreamingMarkdownContent.ets @@ -76,16 +76,14 @@ export struct StreamingMarkdownContent { this.saveRenderedText(); return; } - if (this.text.length < this.renderedText.length || this.text.length < this.targetText.length) { - this.clearTimer(); - this.saveRenderedText(); - if (this.renderedText.length < this.targetText.length) { - this.startTimer(); - } - return; - } - if (!this.text.startsWith(this.renderedText) && this.renderedText.length > 0) { + // The input is already revision-reconciled by the transcript owner. A + // rewrite or deletion is authoritative, not a stale streaming chunk. + // Only append-only growth may continue the previous reveal animation. + if (!this.text.startsWith(this.targetText) || !this.text.startsWith(this.renderedText)) { this.clearTimer(); + this.targetText = this.text; + this.renderedText = this.text; + this.renderedRevision += 1; this.saveRenderedText(); return; } @@ -131,9 +129,6 @@ export struct StreamingMarkdownContent { if (cached.length > 0 && this.text.startsWith(cached)) { return cached; } - if (cached.length > 0) { - return cached; - } return ''; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets index 70a8e131c2..e8647a079a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets @@ -120,7 +120,9 @@ export struct RemoteSurfaceHost { showStatusMetadata: this.presentationState.showStatusMetadata, supportsHarnessProfiles: this.supportsHarnessProfiles(), hasMoreSessions: this.remotePageState.hasMoreSessions, - isBusy: this.remotePageState.conversation.isBusy || this.remotePageState.isLoadingSessions, + // Opening a transcript is supersedable; keep rows tappable while that + // request hydrates so a second choice is not silently swallowed. + isBusy: this.remotePageState.isLoadingSessions, // A pending id means a row was tapped, whether or not the open is slow // enough to have raised a skeleton. Keying the highlight off the // loading flag instead used to tie selection to how long the load took. diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/CatalogRefreshPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/CatalogRefreshPreview.ets new file mode 100644 index 0000000000..aae895e592 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/CatalogRefreshPreview.ets @@ -0,0 +1,82 @@ +import { RecentWorkspaceEntry, RemoteSession } from '../../model/RemoteModels'; +import { RecentRemoteHome } from '../components/RecentRemoteHome'; +import { RemoteSessionList } from '../components/RemoteSessionList'; +import { SidebarDeviceGroup } from '../components/SidebarDeviceGroup'; +import { SidebarWorkspacePicker } from '../components/SidebarWorkspacePicker'; +import { INK, PAGE_BG } from '../components/Theme'; +import { DeviceDirectoryState, emptyDeviceDirectoryEntry } from '../state/DeviceDirectoryState'; + +/** Same-ID replacement fixture. No account, persistence, or remote requests. */ +@ComponentV2 +export struct CatalogRefreshPreview { + @Local directory: DeviceDirectoryState = this.makeDirectory(); + @Local sessions: RemoteSession[] = this.makeSessions(false); + @Local mode: number = 0; + @Local selected: string = ''; + + private workspaces(updated: boolean): RecentWorkspaceEntry[] { + return [{ workspaceId: 'preview-workspace', path: updated ? '/preview-after' : '/preview', name: updated ? 'Workspace after' : 'Workspace before', + lastOpened: '', workspaceKind: 'normal' }]; + } + + private makeSessions(updated: boolean): RemoteSession[] { + return [{ id: 'preview-session', deviceId: 'preview-device', title: updated ? 'Session after' : 'Session before', + agentType: 'code', status: updated ? 'completed' : 'idle', messageCount: updated ? 2 : 0, + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), workspacePath: updated ? '/preview-after' : '/preview', + workspaceName: updated ? 'Workspace after' : 'Workspace before', + workspaceIdentity: { workspaceId: 'preview-workspace', path: updated ? '/preview-after' : '/preview' } }]; + } + + private makeDirectory(): DeviceDirectoryState { + const state = new DeviceDirectoryState(); + const device = emptyDeviceDirectoryEntry('preview-device', 'Preview host', true); + device.workspaces = this.workspaces(false); + device.sessions = this.makeSessions(false); + device.status = 'ready'; + state.replace([device]); + state.setWorkspaceExpanded(device.deviceId, '/preview', true, undefined, undefined, 'preview-workspace'); + state.workspaceLoadState(device.deviceId, '/preview', undefined, undefined, 'preview-workspace').hasLoadedSessions = true; + return state; + } + + build() { + Column() { + Row() { + Button('Sidebar').id('catalog-sidebar').onClick(() => { this.mode = 0; }) + Button('Recent').id('catalog-recent').onClick(() => { this.mode = 1; }) + Button('All').id('catalog-all').onClick(() => { this.mode = 2; }) + } + Row() { + Button('Projects').id('catalog-projects').onClick(() => { this.mode = 3; }) + Button('Picker').id('catalog-picker').onClick(() => { this.mode = 4; }) + } + Button('Replace same IDs').id('catalog-replace').onClick(() => { + this.directory.devices[0].workspaces = this.workspaces(true); + this.sessions = this.makeSessions(true); + this.directory.devices[0].sessions = this.sessions; + }) + Text(`Source: ${this.sessions[0].title}`).id('catalog-source').fontColor(INK) + Text(this.selected).id('catalog-selected').fontColor(INK) + if (this.mode === 0) { + SidebarDeviceGroup({ device: this.directory.devices[0], directoryState: this.directory, sessions: this.sessions, + onOpenSession: (session: RemoteSession): void => { this.selected = session.title; } }) + } else if (this.mode === 1) { + RecentRemoteHome({ connected: true, sessions: this.sessions, + onOpen: (session: RemoteSession): void => { this.selected = session.title; } }) + .layoutWeight(1) + } else if (this.mode === 2) { + RemoteSessionList({ sessions: this.sessions, sortMode: 'time', showStatusMetadata: true, + onOpenSession: (session: RemoteSession): void => { this.selected = session.title; } }) + .layoutWeight(1) + } else if (this.mode === 3) { + RemoteSessionList({ sessions: this.sessions, recentWorkspaces: this.directory.devices[0].workspaces, + onOpenSession: (session: RemoteSession): void => { this.selected = session.title; } }) + .layoutWeight(1) + } else { + SidebarWorkspacePicker({ workspaces: this.directory.devices[0].workspaces, + onSelectWorkspace: (path: string): void => { this.selected = path; } }) + .layoutWeight(1) + } + }.width('100%').height('100%').backgroundColor(PAGE_BG) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ComposerSubmissionPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ComposerSubmissionPreview.ets new file mode 100644 index 0000000000..2dba7d19ea --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ComposerSubmissionPreview.ets @@ -0,0 +1,63 @@ +import { RemoteChatCommandClient, RemoteChatCommandController } from '../../services/RemoteChatCommandController'; +import { RemoteChatCache, RemoteChatCacheStore, RemoteChatCacheSlice } from '../../services/RemoteChatCache'; +import { SteerTurnResult } from '../../model/RemoteModels'; +import { ComposerBar, ComposerPresentation } from '../components/ComposerBar'; +import { INK, PAGE_BG } from '../components/Theme'; + +class PendingSubmissionClient implements RemoteChatCommandClient { + complete: (value: string) => void = () => {}; + async sendMessage(): Promise { return new Promise((resolve) => { this.complete = resolve; }); } + async sendMessageWithImages(): Promise { return this.sendMessage(); } + async steerTurn(): Promise { throw new Error('Unused preview operation'); } + async buildPlan(): Promise { throw new Error('Unused preview operation'); } + async cancelTask(): Promise {} + async renameSession(): Promise {} +} + +class InMemoryPreviewCache implements RemoteChatCacheStore { + async init(_context: Context): Promise {} + async loadSlice(): Promise { return { messages: [], lastMessageId: '' }; } + async appendMessages(): Promise {} + async replaceMessages(): Promise {} + async deleteSession(): Promise {} + async pruneSessions(): Promise {} + async clearAll(): Promise {} +} + +/** Real composer and command owner with a manually acknowledged, network-free send. */ +@ComponentV2 +export struct ComposerSubmissionPreview { + @Local draft: string = 'Submission timing probe'; + @Local bubble: string = ''; + @Local busy: boolean = false; + @Local acknowledged: boolean = false; + @Local wide: boolean = false; + private client: PendingSubmissionClient = new PendingSubmissionClient(); + private command: RemoteChatCommandController = new RemoteChatCommandController(this.client, { + onMessagesLoaded: () => {}, onMessageCountKnown: () => {}, onTimelineReady: () => {}, + onComposerPrepared: () => ({ commit: () => { this.draft = ''; }, rollback: () => {} }), + onSendSucceeded: () => { this.acknowledged = true; }, onSendFailed: () => {}, + onActiveSession: () => {}, onSessionTitleChanged: () => {}, onStatusText: () => {}, + onToast: () => {}, onBusy: (value: boolean) => { this.busy = value; }, onPollRequested: () => {} + }, new RemoteChatCache(new InMemoryPreviewCache(), () => 'preview')); + + build() { + Column({ space: 12 }) { + Button('Acknowledge').id('submission-ack').onClick(() => { this.client.complete('preview-turn'); }) + Button('Next draft').id('submission-next').onClick(() => { this.draft = 'Next draft'; }) + Text(this.acknowledged ? 'Acknowledged' : 'Pending acknowledgment').id('submission-state').fontColor(INK) + Text(this.bubble).id('submission-bubble').fontColor(INK) + Blank() + ComposerBar({ presentation: this.wide ? ComposerPresentation.Floating : ComposerPresentation.Compact, + chatInput: this.draft, isBusy: this.busy, + onChatInputChange: (value: string) => { this.draft = value; }, + onSend: () => { + const text = this.draft; + this.bubble = text; + void this.command.sendPreparedMessage('preview-session', text, 'code', text, [], [], 'preview-message', '', false, true); + } + }) + }.width('100%').height('100%').backgroundColor(PAGE_BG) + .onAreaChange((_old: Area, area: Area) => { this.wide = Number(area.width) >= 600; }) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/DurableTimelinePreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/DurableTimelinePreview.ets new file mode 100644 index 0000000000..17acfdfd20 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/DurableTimelinePreview.ets @@ -0,0 +1,94 @@ +import { ChatMessage } from '../../model/RemoteModels'; +import { ChatTimelineRowStore, ObservableChatTimelineItem } from '../../model/ChatTimelineModels'; +import { DurableSessionReducer, SessionRecord } from '../../services/DurableSessionReducer'; +import { ChatTimelineStore } from '../../services/ChatTimelineStore'; +import { HostSessionEvent } from '../../services/HostSessionStream'; +import { ChatTimeline } from '../components/ChatTimeline'; +import { ComposerBar } from '../components/ComposerBar'; +import { INK, PAGE_BG } from '../components/Theme'; + +/** Network-free replay through the production reducer, store and native rows. */ +@ComponentV2 +export struct DurableTimelinePreview { + private reducer: DurableSessionReducer = new DurableSessionReducer(); + private timeline: ChatTimelineStore = new ChatTimelineStore(); + private rowStore: ChatTimelineRowStore = new ChatTimelineRowStore(); + @Local rows: ObservableChatTimelineItem[] = []; + @Local revision: number = 0; + @Local step: number = 0; + @Local summary: string = ''; + @Local connectionState: string = 'connected'; + @Local active: boolean = true; + @Local stopRequests: number = 0; + + aboutToAppear(): void { + this.timeline.reset('preview'); + this.reducer.apply(this.record('reason', 'thinking', 0, 1, 'Reason probe')); + this.reducer.apply(this.record('answer', 'text', 2, 2, 'Answer probe')); + this.publish(); + } + + private record(id: string, type: string, order: number, revision: number, + content: string, status: string = 'inprogress'): HostSessionEvent { + const payload: SessionRecord = { + sessionId: 'preview', id: `item/${id}`, revision, + turn: { turnId: 'turn', turnIndex: 0, sessionId: 'preview', timestamp: 1, + userMessage: { id: 'user', content: 'Timeline probe', timestamp: 1 }, status }, + round: { id: 'round', turnId: 'turn', roundIndex: 0, timestamp: 2, status: 'completed' }, + item: { type, data: { id, orderIndex: order, timestamp: 3, content, + toolName: 'Read', toolCall: { id, input: new Object() }, status: 'completed' } } + }; + return { session_id: 'preview', event: 'session-record', payload }; + } + + private publish(): void { + const messages = this.reducer.messages(); + const active = messages.find((message: ChatMessage) => message.role === 'assistant' && message.status === 'active'); + this.timeline.applySnapshot({ sessionId: 'preview', cursor: { + pollVersion: 0, knownMessageCount: messages.length, knownModelCatalogVersion: 0 + }, changed: true, title: '', sessionState: active ? 'active' : 'idle', newMessages: [], + messageSnapshot: messages.filter((message: ChatMessage) => message !== active), activeTurn: active, + shouldSyncAfterTurnEnded: false, historyRewritten: false, completedTurnId: '' }); + this.rows = this.rowStore.reconcile(this.timeline.project(false)); + this.revision++; + const state = this.timeline.snapshot(); + this.active = state.activeTurn !== undefined; + const answer = state.activeTurn || state.persistedMessages.find((message: ChatMessage) => message.role === 'assistant'); + this.summary = `step=${this.step} blocks=${answer?.items?.length || 0} active=${state.activeTurn ? 1 : 0}`; + } + + private next(): void { + this.step++; + if (this.step === 1) this.reducer.apply(this.record('tool', 'tool', 1, 3, '')); + if (this.step === 2) { + for (let index = 0; index < 30; index++) this.publish(); + } + if (this.step === 3) this.reducer.apply(this.record('answer', 'text', 2, 4, 'Corrected probe')); + if (this.step === 4) { + const payload: SessionRecord = { sessionId: 'preview', id: 'item/reason', revision: 5, deleted: true }; + this.reducer.apply({ session_id: 'preview', event: 'session-record', payload }); + } + if (this.step === 5) this.reducer.apply(this.record('answer', 'text', 2, 6, 'Final probe', 'completed')); + this.publish(); + } + + build() { + Column({ space: 8 }) { + Button('Next replay').id('timeline-next').onClick(() => { this.next(); }) + Text(this.summary).id('timeline-summary').fontColor(INK) + Row({ space: 8 }) { + Button('Host offline').id('host-offline').onClick(() => { this.connectionState = 'reconnecting'; }) + Button('Host returned').id('host-returned').onClick(() => { + this.reducer.apply(this.record('answer', 'text', 2, 100, 'Interrupted probe', 'cancelled')); + this.connectionState = 'connected'; + this.publish(); + }) + } + Text(`${this.connectionState} stops=${this.stopRequests}`).id('host-state').fontColor(INK) + ChatTimeline({ timelineItems: this.rows, timelineRevision: this.revision, + connectionState: this.connectionState, isBusy: this.active }).layoutWeight(1) + ComposerBar({ canStop: this.active, connectionState: this.connectionState, + onStop: () => { this.stopRequests++; } }) + }.width('100%').height('100%').backgroundColor(PAGE_BG) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets index ed7d6d3af6..39dff431e7 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets @@ -1,4 +1,7 @@ +import { ComposerSubmissionPreview } from './ComposerSubmissionPreview'; +import { DurableTimelinePreview } from './DurableTimelinePreview'; import { DeviceSelectorPreview } from './DeviceSelectorPreview'; +import { CatalogRefreshPreview } from './CatalogRefreshPreview'; import { WelcomeHome } from '../components/WelcomeHome'; import { InteractionMailboxPreview } from './InteractionMailboxPreview'; import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; @@ -33,7 +36,13 @@ struct MobileDesignGallery { } build() { - if ((AppStorage.get('scenarioId') || '').startsWith('device-selector')) { + if (AppStorage.get('scenarioId') === 'durable-timeline') { + DurableTimelinePreview() + } else if (AppStorage.get('scenarioId') === 'composer-submit') { + ComposerSubmissionPreview() + } else if ((AppStorage.get('scenarioId') || '').startsWith('catalog-refresh')) { + CatalogRefreshPreview() + } else if ((AppStorage.get('scenarioId') || '').startsWith('device-selector')) { DeviceSelectorPreview() } else if (AppStorage.get('scenarioId') === 'welcome-home') { WelcomeHome() diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets index 5b453d8aae..7f9a8fdaa5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets @@ -1,5 +1,5 @@ import { InteractionMailboxAction, InteractionMailboxState } from '../../model/InteractionMailbox'; -import { HostCatalogObserver } from '../../services/HostCatalogObserver'; +import { HostCatalogChange, HostCatalogObserver } from '../../services/HostCatalogObserver'; import { WorkspaceToolsViewModel } from '../viewmodel/WorkspaceToolsViewModel'; import { WorkspaceToolsIntent } from '../state/WorkspaceToolsState'; import { BuiltinMiniAppHost } from '../../services/BuiltinMiniAppHost'; @@ -234,8 +234,18 @@ export abstract class AppRootRuntimeComposition { }); private readonly hostCatalogObserver: HostCatalogObserver = new HostCatalogObserver( this.sessionManager, - async (): Promise => { - await Promise.all([this.remoteWorkspaceViewModel.loadRecentWorkspacesInBackground(), this.remoteSessionViewModel.refreshSessions(), this.remoteChatPollingLifecycleController.refreshModelCatalog()]); + async (change: HostCatalogChange): Promise => { + const initial = change.initial === true || + (change.sessionsRevision === undefined && change.workspacesRevision === undefined); + const refreshSessions = initial || change.sessionsRevision !== undefined; + const refreshWorkspaces = initial || change.workspacesRevision !== undefined; + const jobs: Promise[] = []; + if (refreshSessions) { + jobs.push(this.remoteSessionViewModel.refreshSessions(true)); + jobs.push(this.remoteChatPollingLifecycleController.refreshModelCatalog()); + } + if (refreshWorkspaces) jobs.push(this.remoteWorkspaceViewModel.loadRecentWorkspacesInBackground(true)); + await Promise.all(jobs); }, (error: Error): void => { this.remotePageState.setError(ConnectionErrorPolicy.errorText(error)); } ); @@ -400,16 +410,8 @@ export abstract class AppRootRuntimeComposition { onTimelineReady: () => { this.remoteSessionViewModel.settleTranscriptLoading(); }, - onComposerSubmitted: (sessionId: string, text: string, images: SelectedImageAttachment[]) => { - if (this.remotePageState.activeSession.sessionId !== sessionId) { - return; - } - if (this.remotePageState.chatInput.trim() === text.trim()) { - this.remotePageState.setChatInput(''); - } - this.remotePageState.setSelectedImages(this.remotePageState.selectedImages.filter( - (image: SelectedImageAttachment) => !images.some((sent: SelectedImageAttachment) => sent.id === image.id) - )); + onComposerPrepared: (sessionId: string, text: string, images: SelectedImageAttachment[]) => { + return this.remotePageState.conversation.prepareComposerSubmission(sessionId, text, images); }, onSendSucceeded: (turnId: string, pendingActiveId: string, localMessageId: string) => { this.chatTimelineStore.acknowledgeOptimisticTurn(localMessageId, turnId); @@ -560,6 +562,9 @@ export abstract class AppRootRuntimeComposition { this.remoteSessionViewModel.settleTranscriptLoading(); this.remotePageState.setConversationLoading(false); this.remotePageState.setStatusText(ConnectionErrorPolicy.errorText(error)); + // Confirm host reachability immediately; transcript errors alone do + // not prove disconnection, and the probe is generation-fenced. + void this.remoteActivityViewModel.checkConnectionHealth(); } } ); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets index 1fbd378131..1702f038cc 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets @@ -1,3 +1,4 @@ +import { ComposerSubmission } from '../../model/ComposerSubmission'; import { ChatMessage, RemoteModelCatalog, @@ -34,6 +35,7 @@ export class ConversationCoreState { @Trace chatInput: string = ''; @Trace selectedImages: SelectedImageAttachment[] = []; @Trace isVoiceListening: boolean = false; + private composerRevision: number = 0; private readonly defaultAgentType: string; private readonly timelineRevisionTracker: ChatTimelineRevisionTracker = new ChatTimelineRevisionTracker(); private readonly timelineRowStore: ChatTimelineRowStore = new ChatTimelineRowStore(); @@ -48,6 +50,7 @@ export class ConversationCoreState { } setActiveSession(session: SessionSummary): void { + if (this.activeSession.sessionId !== session.sessionId) this.composerRevision++; this.activeSession = { sessionId: session.sessionId, title: session.title, @@ -59,6 +62,7 @@ export class ConversationCoreState { } clearActiveSession(): void { + this.composerRevision++; this.activeSession = ConversationCoreState.emptySession(this.defaultAgentType); this.clearTimeline(); } @@ -110,26 +114,60 @@ export class ConversationCoreState { } setChatInput(chatInput: string): void { + if (this.chatInput === chatInput) return; + this.composerRevision++; this.chatInput = chatInput; } setSelectedImages(selectedImages: SelectedImageAttachment[]): void { + this.composerRevision++; this.selectedImages = selectedImages.slice(); } addSelectedImages(selectedImages: SelectedImageAttachment[]): void { + this.composerRevision++; this.selectedImages = this.selectedImages.concat(selectedImages); } removeSelectedImage(imageId: string): void { + this.composerRevision++; this.selectedImages = this.selectedImages.filter((image: SelectedImageAttachment) => image.id !== imageId); } clearComposer(): void { + this.composerRevision++; this.chatInput = ''; this.selectedImages = []; } + /** Capture ownership before any await; later callbacks cannot consume a newer draft. */ + prepareComposerSubmission(sessionId: string, text: string, images: SelectedImageAttachment[]): ComposerSubmission { + const originalText = this.chatInput; + const originalImages = this.selectedImages.slice(); + const capturedRevision = this.composerRevision; + let committedRevision = -1; + let committed = false; + let finished = false; + return { + commit: (): void => { + if (finished || committed || this.composerRevision !== capturedRevision || + this.activeSession.sessionId !== sessionId) return; + if (this.chatInput.trim() === text.trim()) this.setChatInput(''); + this.setSelectedImages(this.selectedImages.filter((image: SelectedImageAttachment): boolean => + !images.some((sent: SelectedImageAttachment): boolean => sent.id === image.id))); + committedRevision = this.composerRevision; + committed = true; + }, + rollback: (): void => { + if (finished) return; + finished = true; + if (!committed || this.composerRevision !== committedRevision || this.activeSession.sessionId !== sessionId) return; + this.setChatInput(originalText); + this.setSelectedImages(originalImages); + } + }; + } + /** * Drops the complete observable projection of one conversation context. * diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/DeviceDirectoryViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/DeviceDirectoryViewModel.ets index 826928478a..582ad4fd49 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/DeviceDirectoryViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/DeviceDirectoryViewModel.ets @@ -202,6 +202,7 @@ export class DeviceDirectoryViewModel { const target = deviceId.trim(); const entry = this.state.find(target); if (!entry) { + RemoteLogger.warn(`workspace catalog ignored device=${target} reason=device_not_in_directory count=${workspaces.length}`); return; } entry.workspaces = workspaces.slice(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteSessionViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteSessionViewModel.ets index b7795e3af8..f54019eefa 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteSessionViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteSessionViewModel.ets @@ -73,12 +73,13 @@ export class RemoteSessionViewModel { return `${value.slice(0, 6)}...${value.slice(-4)}`; } - async refreshSessions(): Promise { + async refreshSessions(propagateFailure: boolean = false): Promise { await this.sessions.refresh( this.pageState.sessionQuery, this.pageState.sessionFilter, this.hooks.remoteAvailable(), - this.hooks.isConnected() + this.hooks.isConnected(), + propagateFailure ); } 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 a98458d07d..4eb2940da0 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 @@ -109,7 +109,7 @@ export class RemoteTranscriptController { const text = rawText.length > 0 ? rawText : (images.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); const sessionId = this.remote.activeSession.sessionId || ''; - if ((!text && images.length === 0) || !sessionId || this.remote.isBusy || + if ((!text && images.length === 0) || !sessionId || this.remote.isBusy || this.remote.connectionState !== 'connected' || !runtime.connection.ensureAvailable()) { return; } @@ -176,7 +176,7 @@ export class RemoteTranscriptController { this.notify(RemoteI18n.t('chat.planBuildWaitForTurn')); return; } - if (planFilePath.trim().length === 0 || sessionId.length === 0 || this.remote.isBusy || + if (planFilePath.trim().length === 0 || sessionId.length === 0 || this.remote.isBusy || this.remote.connectionState !== 'connected' || !runtime.connection.ensureAvailable()) { return; } @@ -209,7 +209,7 @@ export class RemoteTranscriptController { sessionId, this.remote.activeTurnMessage.id, this.remoteActiveTurnId(), - runtime.connection.ensureAvailable() + this.remote.connectionState === 'connected' && runtime.connection.ensureAvailable() ); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets index b45ed0b083..89541a9635 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets @@ -25,6 +25,9 @@ export class RemoteWorkspaceViewModel { private readonly coordinator: RemoteWorkspaceCoordinator; private readonly hooks: RemoteWorkspaceViewModelHooks; private catalogLoadVersion: number = 0; + // Background catalog invalidation must not cancel a foreground picker's + // result/finally, and opening a picker must not abandon sidebar loading. + private pickerLoadVersion: number = 0; constructor( pageState: RemotePageState, @@ -64,7 +67,7 @@ export class RemoteWorkspaceViewModel { return await this.select(path, true, undefined, undefined, workspaceId); } - async loadRecentWorkspacesInBackground(): Promise { + async loadRecentWorkspacesInBackground(propagateFailure: boolean = false): Promise { const loadVersion = ++this.catalogLoadVersion; const targetId = this.hooks.remoteTargetId(); if (targetId.length === 0) { @@ -101,6 +104,7 @@ export class RemoteWorkspaceViewModel { this.hooks.onCatalogFailed(targetId); } RemoteLogger.warn(`background recent workspace load failed: ${String(err)}`); + if (propagateFailure && this.isCurrentCatalogLoad(loadVersion, targetId)) throw new Error(String(err)); } } @@ -108,24 +112,24 @@ export class RemoteWorkspaceViewModel { if (!this.hooks.isRemoteAvailable()) { return; } - const loadVersion = ++this.catalogLoadVersion; + const loadVersion = ++this.pickerLoadVersion; const targetId = this.hooks.remoteTargetId(); try { this.hooks.onBusy(true); this.hooks.onStatus(RemoteI18n.t('status.loadingRecentWorkspaces')); const recent = await this.coordinator.recentWorkspaces(); - if (!this.isCurrentCatalogLoad(loadVersion, targetId)) { + if (!this.isCurrentPickerLoad(loadVersion, targetId)) { return; } this.pageState.setRecentWorkspaces(recent); this.hooks.onStatus(recent.length > 0 ? RemoteI18n.t('status.chooseWorkspace') : RemoteI18n.t('status.noRecentWorkspaces')); } catch (err) { - if (this.isCurrentCatalogLoad(loadVersion, targetId)) { + if (this.isCurrentPickerLoad(loadVersion, targetId)) { this.hooks.onConnectionFailure(err); } } finally { - if (this.isCurrentCatalogLoad(loadVersion, targetId)) { + if (this.isCurrentPickerLoad(loadVersion, targetId)) { this.hooks.onBusy(false); } } @@ -137,28 +141,32 @@ export class RemoteWorkspaceViewModel { targetId === this.hooks.remoteTargetId(); } + private isCurrentPickerLoad(loadVersion: number, targetId: string): boolean { + return targetId.length > 0 && loadVersion === this.pickerLoadVersion && targetId === this.hooks.remoteTargetId(); + } + private async loadAssistants(): Promise { if (!this.hooks.isRemoteAvailable()) { return; } - const loadVersion = ++this.catalogLoadVersion; + const loadVersion = ++this.pickerLoadVersion; const targetId = this.hooks.remoteTargetId(); try { this.hooks.onBusy(true); this.hooks.onStatus(RemoteI18n.t('status.loadingAssistants')); const assistants = await this.coordinator.assistants(); - if (!this.isCurrentCatalogLoad(loadVersion, targetId)) { + if (!this.isCurrentPickerLoad(loadVersion, targetId)) { return; } this.pageState.setAssistants(assistants); this.hooks.onStatus(assistants.length > 0 ? RemoteI18n.t('status.chooseAssistant') : RemoteI18n.t('status.noAssistants')); } catch (err) { - if (this.isCurrentCatalogLoad(loadVersion, targetId)) { + if (this.isCurrentPickerLoad(loadVersion, targetId)) { this.hooks.onConnectionFailure(err); } } finally { - if (this.isCurrentCatalogLoad(loadVersion, targetId)) { + if (this.isCurrentPickerLoad(loadVersion, targetId)) { this.hooks.onBusy(false); } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatComposerPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatComposerPolicy.ets index 62d544cafa..5d4a6c4926 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatComposerPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatComposerPolicy.ets @@ -14,6 +14,10 @@ export enum ComposerPrimaryAction { } export class ChatComposerPolicy { + static canStop(isTurnRunning: boolean, requiresRemoteConnection: boolean, connectionState: string): boolean { + return isTurnRunning && (!requiresRemoteConnection || connectionState === 'connected'); + } + static canSend( text: string, attachmentCount: number, @@ -23,7 +27,7 @@ export class ChatComposerPolicy { ): boolean { const hasContent = text.trim().length > 0 || attachmentCount > 0; const remoteAvailable = !requiresRemoteConnection || - connectionState === 'connected' || connectionState === 'reconnecting'; + connectionState === 'connected'; return hasContent && !isBusy && remoteAvailable; } 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 abc40a11b3..a0803dc740 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 @@ -21,6 +21,7 @@ export interface ChatSessionSnapshot { title: string; sessionState: string; newMessages: ChatMessage[]; + /** When present, this and activeTurn form a complete authoritative reducer projection. */ messageSnapshot?: ChatMessage[]; activeTurn?: ChatMessage; modelCatalog?: RemoteModelCatalog; @@ -159,11 +160,14 @@ export class ChatSessionController { if (this.stopped || !this.callbacks.canPoll(this.sessionId)) return; const persisted: ChatMessage[] = []; let active: ChatMessage | undefined = undefined; + this.completedTurnId = ''; this.reducer.messages().forEach((message: ChatMessage) => { if (message.role === 'assistant' && (message.status === 'active' || message.status === 'running')) active = message; else { persisted.push(message); - if (message.role === 'assistant') this.completedTurnId = message.turnId || ''; + if (message.role === 'assistant') { + this.completedTurnId = message.status === 'completed' ? (message.turnId || '') : ''; + } } }); this.cursor.knownMessageCount = persisted.length; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineStore.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineStore.ets index edad3f9d50..2b0034e64a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineStore.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineStore.ets @@ -442,7 +442,14 @@ export class ChatTimelineStore { applySnapshot(snapshot: ChatSessionSnapshot): void { this.setCursor(snapshot.cursor); - if (snapshot.messageSnapshot) { + if (snapshot.messageSnapshot !== undefined) { + // The durable reducer already resolves identity, ordering, revisions and + // tombstones. Replace its complete projection, as the shared mobile + // session store does, rather than merging it as a legacy partial poll. + // Clear before persisted messages too: stale active/steering blocks must + // not be carried into an authoritative completed turn. Pending user + // messages remain separate until a host record acknowledges them. + this.clearActiveTurn(); this.setPersistedMessages(snapshot.messageSnapshot); } else if (snapshot.newMessages.length > 0) { this.mergePersistedMessages(snapshot.newMessages); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/HostCatalogObserver.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/HostCatalogObserver.ets index 4583b53221..4f4210d38a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/HostCatalogObserver.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/HostCatalogObserver.ets @@ -1,17 +1,29 @@ import { RemoteSessionManager } from './RemoteSessionManager'; import { HOST_CATALOG_ID, HostSessionEvent, HostSessionStream } from './HostSessionStream'; +export interface HostCatalogChange { + sessionsRevision?: number; + workspacesRevision?: number; + initial?: boolean; +} + +const HOST_CATALOG_NOTICE_MIN_INTERVAL_MS: number = 3000; + /** One host invalidation stream; directory contents remain owned by the runtime. */ export class HostCatalogObserver { private stream?: HostSessionStream; private target: string = ''; private generation: number = 0; private dirty: boolean = false; - private running: boolean = false; + private runningGeneration?: number; + private pendingChange: HostCatalogChange = {}; + private lastSessionsRevision?: number; + private lastWorkspacesRevision?: number; + private lastRefreshAt: number = 0; private manager: RemoteSessionManager; - private refresh: () => Promise; + private refresh: (change: HostCatalogChange) => Promise; private onError: (error: Error) => void; - constructor(manager: RemoteSessionManager, refresh: () => Promise, onError: (error: Error) => void) { + constructor(manager: RemoteSessionManager, refresh: (change: HostCatalogChange) => Promise, onError: (error: Error) => void) { this.manager = manager; this.refresh = refresh; this.onError = onError; } start(target: string): void { @@ -28,13 +40,34 @@ export class HostCatalogObserver { try { this.stream = this.manager.subscribeSession(HOST_CATALOG_ID, { onEvent: async (event: HostSessionEvent): Promise => { - if (generation === this.generation && event.event === 'host-catalog-changed') this.dirty = true; + if (generation === this.generation && event.event === 'host-catalog-changed') { + const payload = (event.payload || {}) as Record; + const sessionsRevision = Number(payload.sessionsRevision); + const workspacesRevision = Number(payload.workspacesRevision); + const hasRevisions = Number.isSafeInteger(sessionsRevision) || Number.isSafeInteger(workspacesRevision); + let revisionChanged = false; + if (Number.isSafeInteger(sessionsRevision) && sessionsRevision !== this.lastSessionsRevision) { + this.pendingChange.sessionsRevision = sessionsRevision; + this.lastSessionsRevision = sessionsRevision; + revisionChanged = true; + } + if (Number.isSafeInteger(workspacesRevision) && workspacesRevision !== this.lastWorkspacesRevision) { + this.pendingChange.workspacesRevision = workspacesRevision; + this.lastWorkspacesRevision = workspacesRevision; + revisionChanged = true; + } + // A revision-bearing event with no revision change is only a + // duplicate hint. Legacy hosts omit both values and remain + // conservatively invalidating everything. + if (!hasRevisions) this.pendingChange.initial = true; + if (!hasRevisions || revisionChanged) this.dirty = true; + } }, onResumed: async (): Promise => { - if (generation === this.generation) this.dirty = true; + if (generation === this.generation) { this.pendingChange.initial = true; this.dirty = true; } }, onGap: async (): Promise => { - if (generation === this.generation) this.dirty = true; + if (generation === this.generation) { this.pendingChange.initial = true; this.dirty = true; } }, onCaughtUp: async (): Promise => { await this.flush(generation); }, onError: (error: Error): void => { if (generation === this.generation) this.onError(error); } @@ -43,20 +76,39 @@ export class HostCatalogObserver { } stop(): void { this.generation++; this.stream?.close(); this.stream = undefined; - this.target = ''; this.dirty = false; + this.target = ''; this.dirty = false; this.pendingChange = {}; + this.lastSessionsRevision = undefined; this.lastWorkspacesRevision = undefined; this.lastRefreshAt = 0; } private async flush(generation: number): Promise { - if (this.running || generation !== this.generation) return; - this.running = true; + if (this.runningGeneration === generation || generation !== this.generation) return; + // A disconnected target may still have an RPC pending. The new stream + // owns its own refresh and must receive its errors to drive backoff. + this.runningGeneration = generation; try { while (this.dirty && generation === this.generation) { - this.dirty = false; await this.refresh(); + this.dirty = false; + const change = this.pendingChange; + if (this.lastRefreshAt === 0) change.initial = true; + this.pendingChange = {}; + const waitMs = Math.max(0, HOST_CATALOG_NOTICE_MIN_INTERVAL_MS - (Date.now() - this.lastRefreshAt)); + if (this.lastRefreshAt > 0 && waitMs > 0) { + await new Promise((resolve) => setTimeout(resolve, waitMs)); + if (generation !== this.generation) return; + } + await this.refresh(change); + if (generation !== this.generation) return; + this.lastRefreshAt = Date.now(); } } catch (error) { - if (generation === this.generation) this.onError(error as Error); + if (generation === this.generation) { + this.pendingChange.initial = true; + this.dirty = true; + this.lastRefreshAt = Date.now(); + // Let the stream's existing backoff retry even if no more hints arrive. + throw new Error(String(error)); + } } finally { - this.running = false; - if (this.dirty && generation !== this.generation) void this.flush(this.generation); + if (this.runningGeneration === generation) this.runningGeneration = undefined; } } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatCommandController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatCommandController.ets index 19dd11ed12..58218deb49 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatCommandController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatCommandController.ets @@ -1,3 +1,4 @@ +import { ComposerSubmission } from '../model/ComposerSubmission'; import { ChatMessage, RemoteImageContext, @@ -38,7 +39,8 @@ export interface RemoteChatCommandCallbacks { // before any request has been sent. Without it the loading skeleton would // outlive the messages it is standing in for. onTimelineReady: () => void; - onComposerSubmitted?: (sessionId: string, text: string, images: SelectedImageAttachment[]) => void; + // Draft ownership is captured before the RPC; the owner guards commits and rollback. + onComposerPrepared?: (sessionId: string, text: string, images: SelectedImageAttachment[]) => ComposerSubmission; onSendSucceeded: (turnId: string, pendingActiveId: string, localMessageId: string) => void; onSteerSucceeded?: ( steeringId: string, @@ -123,17 +125,23 @@ export class RemoteChatCommandController { if ((text.length === 0 && images.length === 0) || sessionId.length === 0 || isBusy || !remoteAvailable) { return; } + let submission: ComposerSubmission | undefined; try { this.callbacks.onBusy(true); this.callbacks.onStatusText(images.length > 0 ? RemoteI18n.t('status.sendImage') : RemoteI18n.t('status.sendCommand')); + // The optimistic user bubble is already published. Consume its draft in + // the same synchronous submission, not after the remote acknowledgment. + submission = this.callbacks.onComposerPrepared?.(sessionId, rawText, images); + submission?.commit(); const requestedTurnId = clientTurnId.trim().length > 0 ? clientTurnId : localMessageId; const turnId = images.length > 0 ? await this.client.sendMessageWithImages(sessionId, text, agentType, imageContexts, requestedTurnId) : await this.client.sendMessage(sessionId, text, agentType, requestedTurnId); - this.callbacks.onComposerSubmitted?.(sessionId, rawText, images); + submission = undefined; this.callbacks.onSendSucceeded(turnId, pendingActiveId, localMessageId); this.callbacks.onStatusText(RemoteI18n.t('status.sentWaiting')); } catch (err) { + submission?.rollback(); this.callbacks.onSendFailed(rawText, images, localMessageId, pendingActiveId); this.callbacks.onStatusText(ConnectionErrorPolicy.errorText(err)); } finally { @@ -155,8 +163,10 @@ export class RemoteChatCommandController { isBusy || !remoteAvailable) { return; } + let submission: ComposerSubmission | undefined; try { this.callbacks.onBusy(true); + submission = this.callbacks.onComposerPrepared?.(sessionId, displayText, images); const result = await this.client.steerTurn( sessionId, turnId, @@ -164,13 +174,17 @@ export class RemoteChatCommandController { displayText, imageContexts ); - this.callbacks.onComposerSubmitted?.(sessionId, displayText, images); + // Steering has no optimistic bubble: consume its captured draft only + // when the host accepts it, alongside the accepted steering item. + submission?.commit(); + submission = undefined; if (this.callbacks.onSteerSucceeded) { this.callbacks.onSteerSucceeded(result.steeringId, result.turnId, displayText, images); } this.callbacks.onStatusText(RemoteI18n.t('status.sentWaiting')); this.callbacks.onPollRequested(); } catch (err) { + submission?.rollback(); this.callbacks.onSendFailed(displayText, images, '', ''); const errorText = ConnectionErrorPolicy.errorText(err); this.callbacks.onStatusText(errorText); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionController.ets index 23cbd5b3a0..f4efa440be 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionController.ets @@ -86,7 +86,8 @@ export class RemoteSessionController { query: string, filter: string, remoteAvailable: boolean, - isConnected: boolean + isConnected: boolean, + propagateFailure: boolean = false ): Promise { if (!remoteAvailable) { return; @@ -113,6 +114,7 @@ export class RemoteSessionController { if (generation !== this.listGeneration) return; this.callbacks.onSessionError(ConnectionErrorPolicy.errorText(err)); this.callbacks.onConnectionFailed(err); + if (propagateFailure) throw new Error(String(err)); } finally { if (generation === this.listGeneration) this.finishListRequest(); } diff --git a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets index 3c8c42fe3d..168e945320 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets @@ -1849,8 +1849,8 @@ export default function remoteControllersUnitTest() { onMessagesLoaded: (_messages: ChatMessage[], _hasMoreMessages: boolean) => {}, onMessageCountKnown: (_pollVersion: number, _knownMessageCount: number) => {}, onTimelineReady: () => {}, - onComposerSubmitted: (sessionId: string, _text: string, images: SelectedImageAttachment[]) => { - submittedImages.push(`${sessionId}:${images[0].id}`); + onComposerPrepared: (sessionId: string, _text: string, images: SelectedImageAttachment[]) => { + return { commit: () => { submittedImages.push(`${sessionId}:${images.length > 0 ? images[0].id : ''}`); }, rollback: () => {} }; }, onSendSucceeded: (turnId: string, pendingActiveId: string, localMessageId: string) => { succeeded.push(`${turnId}:${pendingActiveId}:${localMessageId}`); @@ -1907,7 +1907,7 @@ export default function remoteControllersUnitTest() { expect(client.sendRequests[1]).assertEqual('text:session-1:Retry text:code'); expect(client.sendTurnIds[0]).assertEqual('local-1'); expect(client.sendTurnIds[1]).assertEqual('local-2'); - expect(submittedImages.length).assertEqual(1); + expect(submittedImages.length).assertEqual(2); expect(submittedImages[0]).assertEqual('session-1:image-1'); expect(succeeded[0]).assertEqual('turn-1:pending-1:local-1'); expect(failed[0]).assertEqual('Retry text:0:local-2:pending-2'); diff --git a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets index ed0d31a6f5..01b30c43e8 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets @@ -1071,7 +1071,7 @@ export default function transportAndGeneralChatUnitTest() { it('still gates Remote chat when the desktop connection is unavailable', 0, () => { expect(ChatComposerPolicy.canSend('运行任务', 0, false, true, 'failed')).assertEqual(false); expect(ChatComposerPolicy.canSend('运行任务', 0, false, true, 'connected')).assertEqual(true); - expect(ChatComposerPolicy.canSend('运行任务', 0, false, true, 'reconnecting')).assertEqual(true); + expect(ChatComposerPolicy.canSend('运行任务', 0, false, true, 'reconnecting')).assertEqual(false); }); it('lets a draft outrank a running turn where the message can be handed over', 0, () => { diff --git a/src/apps/mobile/harmonyos/tools/check-catalog-refresh.py b/src/apps/mobile/harmonyos/tools/check-catalog-refresh.py new file mode 100644 index 0000000000..2e8d8b91ab --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/check-catalog-refresh.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Exercise the installed debug HAP's isolated catalog fixture on a real device. + +Runs in the current screen posture. Always returns to the normal App afterward. +No account, remote workspace, or session is changed by the fixture. +""" +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import time + +BUNDLE_CONTRACT = (Path(__file__).resolve().parent.parent + / 'entry/src/main/ets/services/HarmonyUpgradeIdentityContract.ets') + + +def app_bundle(): + """Read the retained install identity from its single declared boundary.""" + match = re.search(r"APP_BUNDLE:\s*string\s*=\s*'([^']+)'", BUNDLE_CONTRACT.read_text(encoding='utf-8')) + if match is None: + raise SystemExit(f'App bundle id not found in {BUNDLE_CONTRACT}') + return match.group(1) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--hdc', default=os.environ.get('HDC', 'hdc')) + parser.add_argument('--device', help='HDC target when multiple devices are attached') + parser.add_argument('--dark', action='store_true') + args = parser.parse_args() + command = [args.hdc] + (['-t', args.device] if args.device else []) + output = Path(tempfile.mkdtemp(prefix='openbitfun-catalog-')) + print(f'Evidence: {output}', flush=True) + + def run(*parts): + return subprocess.check_output(command + list(parts), text=True, timeout=30) + + def start(preview=False): + bundle = app_bundle() + run('shell', 'aa', 'force-stop', bundle) + params = ['shell', 'aa', 'start', '-a', 'EntryAbility', '-b', bundle] + if preview: + params += ['--ps', 'openbitfunDesignPreview', + 'catalog-refresh-dark' if args.dark else 'catalog-refresh'] + run(*params) + + def layout(label): + remote = run('shell', 'uitest', 'dumpLayout').strip().split('saved to:')[-1] + local = output / f'{label}.json' + run('file', 'recv', remote, str(local)) + nodes = [] + + def walk(node): + nodes.append(node.get('attributes', {})) + for child in node.get('children', []): + walk(child) + + walk(json.loads(local.read_text())) + return nodes + + def click(nodes, field, value): + node = next(node for node in nodes if node.get(field) == value) + left, top, right, bottom = map(int, re.findall(r'-?\d+', node['bounds'])) + run('shell', 'uitest', 'uiInput', 'click', str((left + right) // 2), str((top + bottom) // 2)) + time.sleep(0.4) + + try: + for mode in ['sidebar', 'recent', 'all', 'projects', 'picker']: + start(preview=True) + initial = [] + for _ in range(8): + time.sleep(0.5) + initial = layout(f'{mode}-initial') + if any(node.get('id') == f'catalog-{mode}' for node in initial): + break + click(initial, 'id', f'catalog-{mode}') + before = layout(f'{mode}-before') + expected_before = 'Workspace before' if mode == 'picker' else 'Session before' + assert any(node.get('text') == expected_before for node in before), mode + click(before, 'id', 'catalog-replace') + after = layout(f'{mode}-after') + texts = [node.get('text') for node in after] + assert 'Source: Session after' in texts, 'Fixture replacement did not run' + assert 'Session before' not in texts and 'Workspace before' not in texts, mode + expected = 'Workspace after' if mode == 'picker' else 'Session after' + assert expected in texts, (mode, texts) + if mode in ['sidebar', 'projects', 'picker']: + assert 'Workspace after' in texts, mode + if mode == 'all': + assert 'completed' in texts and 'idle' not in texts, 'Session status did not refresh' + click(after, 'text', expected) + selected = next(node.get('text') for node in layout(f'{mode}-tap') + if node.get('id') == 'catalog-selected') + assert selected == ('/preview-after' if mode == 'picker' else 'Session after'), (mode, selected) + print(f'PASS {mode}: same-ID content and click use the current snapshot', flush=True) + remote_image = '/data/local/tmp/openbitfun-catalog-refresh.jpeg' + run('shell', 'snapshot_display', '-f', remote_image) + run('file', 'recv', remote_image, str(output / 'catalog-refresh.jpeg')) + finally: + start() + + +if __name__ == '__main__': + main() diff --git a/src/apps/mobile/harmonyos/tools/tests/composer-submit.test.cjs b/src/apps/mobile/harmonyos/tools/tests/composer-submit.test.cjs new file mode 100644 index 0000000000..b35a18e86e --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/composer-submit.test.cjs @@ -0,0 +1,98 @@ +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'); +const source = fs.readFileSync(path.join(__dirname, '../../entry/src/main/ets/services/RemoteChatCommandController.ets'), 'utf8'); +const js = ts.transpileModule(source, {compilerOptions: {target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS}}).outputText; +const exported = {}; +new Function('require', 'exports', js)(name => name.endsWith('RemoteI18n') ? {RemoteI18n: {t: key => key}} : + name.endsWith('ConnectionErrorPolicy') ? {ConnectionErrorPolicy: {errorText: String}} : {}, exported); +function fixture(images = []) { + let resolve, reject; + const response = new Promise((yes, no) => {resolve = yes; reject = no;}); + const state = {draft: 'Hello', images, submits: 0, successes: 0, failures: [], requests: [], busy: false}; + const send = async (...args) => {state.requests.push(args); assert.equal(state.draft, '', 'draft must clear before RPC'); return response;}; + const controller = new exported.RemoteChatCommandController({sendMessage: send, sendMessageWithImages: send}, { + onBusy: value => {state.busy = value;}, onStatusText() {}, + onComposerPrepared: () => ({commit: () => {state.draft = ''; state.images = []; state.submits++;}, rollback() {}}), + onSendSucceeded: () => {state.successes++;}, + onSendFailed: (...args) => state.failures.push(args) + }, {}); + return {state, resolve, reject, send: (busy=false, available=true) => controller.sendPreparedMessage( + 'session', 'Hello', 'code', 'Hello', images, [], 'local', 'pending', busy, available)}; +} +for (const images of [[], [{id: 'image'}]]) { + test(`submission clears immediately, late acknowledgment preserves next draft (images=${images.length})`, async () => { + const f=fixture(images); const pending=f.send(); + assert.equal(f.state.draft, ''); assert.deepEqual(f.state.images, []); + assert.equal(f.state.busy, true); assert.equal(f.state.successes, 0); + f.state.draft='Hello'; // Even an identical next draft must survive the ACK. + f.resolve('turn'); await pending; + assert.equal(f.state.draft, 'Hello'); assert.equal(f.state.submits, 1); + assert.equal(f.state.successes, 1); assert.equal(f.state.busy, false); + }); +} +test('failure returns the original payload for the failed bubble', async () => { + const images=[{id:'image'}]; const f=fixture(images); const pending=f.send(); + f.reject(Error('offline')); await pending; + assert.deepEqual(f.state.failures, [['Hello', images, 'local', 'pending']]); + assert.equal(f.state.busy, false); +}); +for (const [busy, available] of [[true,true],[false,false]]) { + test(`rejected send keeps its draft (busy=${busy}, available=${available})`, async () => { + const f=fixture(); await f.send(busy, available); + assert.equal(f.state.draft, 'Hello'); assert.equal(f.state.submits, 0); + assert.equal(f.state.requests.length, 0); + }); +} + +const coreSource = fs.readFileSync(path.join(__dirname, '../../entry/src/main/ets/pages/state/ConversationCoreState.ets'), 'utf8') + .replace(/@ObservedV2\s*/g, '').replace(/@Trace\s*/g, ''); +const coreJs = ts.transpileModule(coreSource, {compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.CommonJS}}).outputText; +const coreExports = {}; +new Function('require','exports',coreJs)(name => name.endsWith('RemoteUiState') ? {RemoteUiState:{emptyActiveTurn:()=>({}),emptyModelCatalog:()=>({})}} : + {ChatTimelineRevisionTracker:class{reset(){return 0;}},ChatTimelineRowStore:class{clear(){}}},coreExports); +function submitComposer(core, session, text, images) { + const submission=core.prepareComposerSubmission(session,text,images); submission.commit(); return () => submission.rollback(); +} +function composer() { + const core=new coreExports.ConversationCoreState('code'); + core.setActiveSession({sessionId:'session',title:'Test',agentType:'code'}); + core.setChatInput(' Original '); core.setSelectedImages([{id:'sent-image'}]); + return core; +} +test('submission owns an exact text/attachment snapshot and failure restores it once', () => { + const core=composer(); const restore=submitComposer(core,'session','Original',[{id:'sent-image'}]); + assert.equal(core.chatInput,''); assert.deepEqual(core.selectedImages,[]); + restore(); assert.equal(core.chatInput,' Original '); assert.deepEqual(core.selectedImages,[{id:'sent-image'}]); + core.setChatInput('Next'); restore(); assert.equal(core.chatInput,'Next'); +}); +for (const mutation of ['new text','type then erase','new image','switch away and back','reset']) { + test(`late failure cannot restore a draft after ${mutation}`, () => { + const core=composer(); const restore=submitComposer(core,'session','Original',[{id:'sent-image'}]); + if(mutation==='new text')core.setChatInput('Next'); + if(mutation==='type then erase'){core.setChatInput('Next');core.setChatInput('');} + if(mutation==='new image')core.addSelectedImages([{id:'new-image'}]); + if(mutation==='switch away and back'){core.setActiveSession({sessionId:'other'});core.setActiveSession({sessionId:'session'});} + if(mutation==='reset')core.reset(); + const text=core.chatInput, images=core.selectedImages.slice(); restore(); + assert.equal(core.chatInput,text);assert.deepEqual(core.selectedImages,images); + }); +} +test('submission for a stale session leaves the visible composer untouched', () => { + const core=composer();submitComposer(core,'other','Original',[{id:'sent-image'}])(); + assert.equal(core.chatInput,' Original ');assert.deepEqual(core.selectedImages,[{id:'sent-image'}]); +}); +for(const fail of [false,true])test(`steering retains its draft until ACK and handles outcome (failure=${fail})`,async()=>{ + const core=composer(); let resolve,reject; + const response=new Promise((yes,no)=>{resolve=yes;reject=no;}); + const controller=new exported.RemoteChatCommandController({steerTurn:()=>response},{ + onBusy(){},onStatusText(){},onToast(){},onPollRequested(){},onSteerSucceeded(){},onSendFailed(){}, + onComposerPrepared:(session,text,images)=>core.prepareComposerSubmission(session,text,images) + },{}); + const pending=controller.steerPreparedMessage('session','turn','Original','Original',[{id:'sent-image'}],[],false,true); + assert.equal(core.chatInput,' Original ');assert.deepEqual(core.selectedImages,[{id:'sent-image'}]); + if(fail)reject(Error('offline'));else {core.setChatInput('Original');resolve({steeringId:'steer',turnId:'turn'});} + await pending;assert.equal(core.chatInput,fail?' Original ':'Original'); +}); diff --git a/src/apps/mobile/harmonyos/tools/tests/connection-health.test.cjs b/src/apps/mobile/harmonyos/tools/tests/connection-health.test.cjs index 7d8055bda9..0f65fccf5d 100644 --- a/src/apps/mobile/harmonyos/tools/tests/connection-health.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/connection-health.test.cjs @@ -216,3 +216,32 @@ test('recovery keeps selected device header geometry while unbound home stays em assert.equal(RemoteCompactHomePolicy.headerSubtitle('idle', 'Studio'), ''); assert.equal(RemoteCompactHomePolicy.headerSubtitle('reconnecting', ''), ''); }); + +const { ChatComposerPolicy } = load('services/ChatComposerPolicy'); +test('a retained active turn can be stopped only while its host is connected', () => { + for (const phase of ['idle', 'reconnecting', 'disconnected', 'failed']) { + assert.equal(ChatComposerPolicy.canStop(true, true, phase), false, phase); + } + assert.equal(ChatComposerPolicy.canStop(true, true, 'connected'), true); + assert.equal(ChatComposerPolicy.canStop(false, true, 'connected'), false); + assert.equal(ChatComposerPolicy.canStop(true, false, 'disconnected'), true); +}); +test('failed host probe retains the transcript and restores its stream when the host returns', async () => { + const f = activityFixture(); let connected = true, reconnecting = false; + f.hooks.isConnected = () => connected; f.hooks.isReconnecting = () => reconnecting; + f.hooks.isRemoteChat = () => true; + f.hooks.onConnectionState = state => { connected = state === 'connected'; reconnecting = state === 'reconnecting'; f.events.push(['state', state]); }; + f.hooks.onStopPolling = () => f.events.push(['stop-stream']); + f.hooks.onStartPolling = () => f.events.push(['resume-stream']); + const pending = f.vm.checkConnectionHealth(); f.probe.reject(Error('Host offline')); await pending; + assert.equal(reconnecting, true); assert.ok(f.events.some(e => e[0] === 'stop-stream')); + f.connection.ping = async () => true; await f.vm.checkConnectionHealth(); + assert.equal(connected, true); assert.ok(f.events.some(e => e[0] === 'resume-stream')); +}); +test('remote sending requires connected host and remains available during a live turn', () => { + for (const phase of ['idle', 'pairing', 'connected', 'reconnecting', 'disconnected', 'failed']) { + assert.equal(ChatComposerPolicy.canSend('draft', 0, false, true, phase), phase === 'connected', phase); + assert.equal(ChatComposerPolicy.primaryAction('draft', 0, false, false, true, true, true, phase, true), + phase === 'connected' ? 'send' : 'send_blocked', phase); + } +}); diff --git a/src/apps/mobile/harmonyos/tools/tests/host-catalog.test.cjs b/src/apps/mobile/harmonyos/tools/tests/host-catalog.test.cjs index 3a32c74150..0bcb434e47 100644 --- a/src/apps/mobile/harmonyos/tools/tests/host-catalog.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/host-catalog.test.cjs @@ -7,14 +7,19 @@ const source = fs.readFileSync(path.join(__dirname, '../../entry/src/main/ets/se const js = ts.transpileModule(source, { compilerOptions: {target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS} }).outputText; const exported = {}; new Function('require', 'exports', js)(() => ({ HOST_CATALOG_ID: '@host/catalog' }), exported); const { HostCatalogObserver } = exported; -function fixture(refresh) { +function deferred() { + let resolve; + const promise = new Promise(yes => {resolve = yes;}); + return {promise, resolve}; +} +function fixture(refresh, onError = error=>{throw error;}) { const streams = []; const manager = { subscribeSession(id, callbacks) { assert.equal(id, '@host/catalog'); const stream = {callbacks, closed:false, isClosed(){return this.closed;}, close(){this.closed=true;}, wake(){}}; streams.push(stream); return stream; }}; - return {observer: new HostCatalogObserver(manager, refresh, error=>{throw error;}), streams}; + return {observer: new HostCatalogObserver(manager, refresh, onError), streams}; } test('host catalog bursts coalesce, reconnect resets invalidation and unchanged wakes do not poll', async()=>{ let reads=0; const f=fixture(async()=>{reads++;}); f.observer.start('host'); @@ -42,3 +47,53 @@ test('switching runtime closes old catalog and ignores late old callbacks', asyn await f.streams[1].callbacks.onCaughtUp(); assert.equal(reads,1); f.observer.stop(); await f.streams[1].callbacks.onResumed(); await f.streams[1].callbacks.onCaughtUp(); assert.equal(reads,1); }); +test('revision hints select the affected catalog and duplicate revisions are ignored', async()=>{ + const changes=[]; const f=fixture(async change=>{changes.push(change);}); f.observer.start('host'); + await f.streams[0].callbacks.onCaughtUp(); + assert.deepEqual(changes, [{initial:true}]); + f.observer.lastRefreshAt=Date.now()-3000; + await f.streams[0].callbacks.onEvent({event:'host-catalog-changed',payload:{sessionsRevision:4,workspacesRevision:8}}); + await f.streams[0].callbacks.onCaughtUp(); + assert.deepEqual(changes.at(-1), {sessionsRevision:4,workspacesRevision:8}); + f.observer.lastRefreshAt=Date.now()-3000; + await f.streams[0].callbacks.onEvent({event:'host-catalog-changed',payload:{sessionsRevision:4,workspacesRevision:9}}); + await f.streams[0].callbacks.onCaughtUp(); + assert.deepEqual(changes.at(-1), {workspacesRevision:9}); +}); + +test('a failed refresh retains invalidation for the next caught-up callback', async()=>{ + let attempts=0; const errors=[]; + const f=fixture(async()=>{ if(++attempts===1) throw new Error('offline'); }, e=>errors.push(e)); + f.observer.start('host'); const c=f.streams[0].callbacks; + await assert.rejects(c.onCaughtUp(), /offline/); + f.observer.lastRefreshAt=Date.now()-3000; + await c.onCaughtUp(); assert.equal(attempts,2); + await c.onCaughtUp(); assert.equal(attempts,2); +}); +test('legacy hints survive coalescing with revision hints', async()=>{ + const changes=[]; const f=fixture(async c=>changes.push(c)); + f.observer.start('host'); const c=f.streams[0].callbacks; + await c.onCaughtUp(); f.observer.lastRefreshAt=Date.now()-3000; + await c.onEvent({event:'host-catalog-changed'}); + await c.onEvent({event:'host-catalog-changed',payload:{sessionsRevision:1}}); + await c.onCaughtUp(); assert.equal(changes.at(-1).initial,true); +}); + +test('a new target owns its refresh and errors while the old refresh is pending', async () => { + const old = deferred(); let attempts = 0; + const f = fixture(async () => { + if (++attempts === 1) return old.promise; + throw Error('New target unavailable'); + }); + f.observer.start('a'); + const oldRefresh = f.streams[0].callbacks.onCaughtUp(); + f.observer.start('b'); + try { + await assert.rejects(f.streams[1].callbacks.onCaughtUp(), /New target unavailable/); + const newRefreshAt = f.observer.lastRefreshAt; + old.resolve(); await oldRefresh; + assert.equal(f.observer.lastRefreshAt, newRefreshAt, 'old completion cannot change the new target pacing'); + } finally { + f.observer.stop(); old.resolve(); await oldRefresh; + } +}); 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 e80cb9f79a..0db2a974f0 100644 --- a/src/apps/mobile/harmonyos/tools/tests/session-record.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/session-record.test.cjs @@ -266,3 +266,163 @@ test('history publishes user and assistant together after reduction, including a assert.equal(snapshots.length, count + 2, 'realtime must still publish immediately'); controller.stop(); }); + +const { ChatTimelineStore } = load('ChatTimelineStore', { './RemoteUiState': load('RemoteUiState') }); +async function durableTimelineHarness() { + const timeline = new ChatTimelineStore(); + timeline.reset('session'); + let stream; + const { ChatSessionController } = load('ChatSessionController', { + './DurableSessionReducer': reducerModule, './InteractionMailboxStore': mailboxModule + }); + const controller = new ChatSessionController({ + getModelCatalog: async () => ({ version: 1, models: [], default_models: {} }), + subscribeSession: (_id, callbacks) => { stream = callbacks; return { wake() {}, close() {} }; } + }, { onSnapshot: snapshot => timeline.applySnapshot(snapshot), canPoll: () => true, + onError: error => { throw error; } }); + controller.start('session', { pollVersion: 0, knownMessageCount: 0, knownModelCatalogVersion: 0 }); + await stream.onCaughtUp(); + return { timeline, stream, controller }; +} +function orderedRecord(revision, id, type, order, content) { + const event = record(revision, content, 'inprogress', id); + event.payload.item.type = type; + event.payload.item.data.orderIndex = order; + if (type === 'tool') Object.assign(event.payload.item.data, { + toolName: 'Read', toolCall: { id, input: {} }, status: 'running' + }); + return event; +} +test('durable controller to timeline: late insertion and repeated publications are idempotent', async () => { + const { timeline, stream } = await durableTimelineHarness(); + await stream.onEvent(orderedRecord(1, 'think', 'thinking', 0, 'reason')); + await stream.onEvent(orderedRecord(2, 'answer', 'text', 2, 'answer')); + await stream.onEvent(orderedRecord(3, 'tool', 'tool', 1, '')); + for (let i = 0; i < 30; i++) { + await stream.onCaughtUp(); + const active = timeline.snapshot().activeTurn; + assert.deepEqual(active.items.map(item => item.type), ['thinking', 'tool', 'text']); + assert.equal(active.items[2].content, 'answer'); + } +}); +test('durable controller to timeline: authoritative corrections replace text and thinking', async () => { + const { timeline, stream } = await durableTimelineHarness(); + await stream.onEvent(orderedRecord(1, 'think', 'thinking', 0, 'old long reasoning')); + await stream.onEvent(orderedRecord(2, 'answer', 'text', 1, 'old long answer')); + await stream.onEvent(orderedRecord(3, 'think', 'thinking', 0, 'new')); + await stream.onEvent(orderedRecord(4, 'answer', 'text', 1, 'fixed')); + const active = timeline.snapshot().activeTurn; + assert.equal(active.text, 'fixed'); assert.equal(active.thinking, 'new'); + assert.deepEqual(active.items.map(item => item.content), ['new', 'fixed']); +}); +test('durable controller to timeline: tombstones and superseded records remove visible content', async () => { + for (const removal of ['deleted', 'superseded', 'retry_superseded']) { + const { timeline, stream } = await durableTimelineHarness(); + const first = orderedRecord(1, 'answer', 'text', 0, 'obsolete'); + await stream.onEvent(first); + const removed = orderedRecord(2, 'answer', 'text', 0, 'obsolete'); + if (removal === 'deleted') { removed.payload.deleted = true; delete removed.payload.item; } + else removed.payload.item.data.status = removal; + await stream.onEvent(removed); + await stream.onEvent(first); + assert.deepEqual(timeline.snapshot().activeTurn.items, [], removal); + assert.equal(timeline.snapshot().activeTurn.text, '', removal); + } +}); +test('durable controller to timeline: gap replay discards old active content', async () => { + const { timeline, stream } = await durableTimelineHarness(); + await stream.onEvent(record(5, 'old epoch')); + await stream.onGap(); + await stream.onEvent(record(1, 'new epoch')); + await stream.onCaughtUp(); + assert.equal(timeline.snapshot().activeTurn.text, 'new epoch'); + await stream.onGap(); await stream.onCaughtUp(); + assert.equal(timeline.snapshot().activeTurn, undefined); + assert.deepEqual(timeline.snapshot().persistedMessages, []); +}); +test('durable controller to timeline: completion replaces active row without carrying obsolete blocks', async () => { + const { timeline, stream } = await durableTimelineHarness(); + await stream.onEvent(record(1, 'draft')); + await stream.onEvent(record(2, 'final', 'completed')); + await stream.onCaughtUp(); + assert.equal(timeline.snapshot().activeTurn, undefined); + const assistants = timeline.snapshot().persistedMessages.filter(row => row.role === 'assistant'); + assert.equal(assistants.length, 1); + assert.equal(assistants[0].text, 'final'); assert.equal(assistants[0].items.length, 1); +}); +test('durable snapshot retains pending local messages until their host record arrives', async () => { + const { timeline, stream } = await durableTimelineHarness(); + timeline.appendOptimisticMessage({ id: 'local', turnId: 'turn', role: 'user', text: 'question' }); + await stream.onCaughtUp(); + assert.equal(timeline.snapshot().optimisticMessages.length, 1); + await stream.onEvent(record(1, 'answer')); + assert.equal(timeline.snapshot().optimisticMessages.length, 0); + assert.equal(timeline.snapshot().persistedMessages.filter(row => row.role === 'user').length, 1); +}); +test('legacy partial snapshots still preserve omitted active content', () => { + const timeline = new ChatTimelineStore(); + timeline.reset('session'); + const active = { id: 'active-turn', turnId: 'turn', role: 'assistant', status: 'active', + text: 'answer', items: [{ type: 'text', content: 'answer' }] }; + timeline.setActiveTurn(active); + timeline.applySnapshot({ sessionId: 'session', cursor: { + pollVersion: 1, knownMessageCount: 0, knownModelCatalogVersion: 0 + }, newMessages: [], activeTurn: { ...active, text: '', items: [] } }); + assert.equal(timeline.snapshot().activeTurn.text, 'answer'); + assert.deepEqual(timeline.snapshot().activeTurn.items, active.items); +}); +test('host restart terminal record settles active turn without reporting successful completion', async () => { + const { ChatSessionController } = load('ChatSessionController', { './DurableSessionReducer': reducerModule, './InteractionMailboxStore': mailboxModule }); + let stream, snapshot; + const timeline = new ChatTimelineStore(); timeline.reset('session'); + const controller = new ChatSessionController({ getModelCatalog: async () => ({version:1,models:[],default_models:{}}), + subscribeSession: (_id, callbacks) => { stream=callbacks; return {wake(){},close(){}}; } + }, { onSnapshot: value => { snapshot=value; timeline.applySnapshot(value); }, canPoll:()=>true, onError:error=>{throw error;} }); + controller.start('session',{pollVersion:0,knownMessageCount:0,knownModelCatalogVersion:0}); + await stream.onEvent(record(1,'partial')); await stream.onCaughtUp(); + assert.ok(timeline.snapshot().activeTurn); + await stream.onGap(); + await stream.onEvent(record(1,'partial','cancelled')); await stream.onCaughtUp(); + assert.equal(timeline.snapshot().activeTurn,undefined); + assert.equal(timeline.snapshot().persistedMessages.at(-1).text,'partial'); + assert.equal(timeline.snapshot().persistedMessages.at(-1).status,'cancelled'); + assert.equal(snapshot.completedTurnId,''); + assert.equal(snapshot.sessionState,'idle'); +}); +test('after host restart the same conversation sends a new turn instead of steering the dead turn', async () => { + const { timeline, stream } = await durableTimelineHarness(); + await stream.onEvent(record(1, 'partial')); + await stream.onGap(); await stream.onEvent(record(1, 'partial', 'cancelled')); await stream.onCaughtUp(); + const ui = load('RemoteUiState'); + const i18n = { RemoteI18n: { t: key => key } }; + const logger = { RemoteLogger: { info() {} } }; + const { RemoteChatCommandController } = load('RemoteChatCommandController', { '../i18n/RemoteI18n': i18n, './RemoteLogger': logger }); + const requests = []; + const command = new RemoteChatCommandController({ + sendMessage: async (sessionId, text) => { requests.push([sessionId, text]); return 'new-turn'; }, + steerTurn: async () => { throw Error('Must not steer the interrupted turn'); } + }, { onBusy(){}, onStatusText(){}, onSendSucceeded(){}, onSendFailed(error){throw Error(error);}, onPollRequested(){} }, {}); + const { RemoteTranscriptController } = load('../pages/viewmodel/RemoteTranscriptController', { + '../../services/ChatTimelineStore': { ChatTimelineStore }, '../../services/RemoteUiState': ui, + '../../services/RemoteLogger': logger, '../../i18n/RemoteI18n': i18n, + '../../services/Encoding': { Encoding: { randomId: () => 'new-turn' } }, + './ConversationRuntime': { requireRemoteRuntime: value => value, shortSessionId: value => value } + }); + const remote = { chatInput:'continue after restart', selectedImages:[], isBusy:false, isVoiceListening:false, + connectionState:'connected', activeSession:{sessionId:'session',agentType:'code'}, activeTurnMessage:timeline.activeTurnOrEmpty(), + supportsHostCapability:()=>true }; + const controller = new RemoteTranscriptController(remote, { + timeline, chat:command, connection:{ensureAvailable:()=>true}, polling:{nudge(){}}, hooks:{} + }, ()=>{}); + controller.syncRemoteTimeline = () => { remote.activeTurnMessage=timeline.activeTurnOrEmpty(); }; + controller.startRemotePolling = () => {}; + remote.connectionState = 'reconnecting'; + await controller.sendRemoteMessage(); + assert.deepEqual(requests, []); + assert.equal(remote.chatInput, 'continue after restart'); + assert.equal(timeline.snapshot().optimisticMessages.length, 0); + remote.connectionState = 'connected'; + await controller.sendRemoteMessage(); + assert.deepEqual(requests,[['session','continue after restart']]); + assert.equal(timeline.snapshot().persistedMessages.at(-1).text,'partial'); +}); diff --git a/src/apps/mobile/harmonyos/tools/tests/streaming-markdown.test.cjs b/src/apps/mobile/harmonyos/tools/tests/streaming-markdown.test.cjs new file mode 100644 index 0000000000..ba74dd5fe8 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/streaming-markdown.test.cjs @@ -0,0 +1,49 @@ +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'); +// Exercise the actual lifecycle methods; only ArkUI's declarative build and +// decorators are removed for the host runner. Native replay covers the view. +const source = fs.readFileSync(path.join(__dirname, '../../entry/src/main/ets/pages/components/StreamingMarkdownContent.ets'), 'utf8') + .replace(/@ComponentV2\s*/g, '').replace(/@(Param|Local|Event)\s*/g, '') + .replace(/@Monitor\([^\n]*\)\s*/g, '').replace('export struct ', 'export class ') + .replace(/ build\(\) \{[\s\S]*?\n private handleTextChanged/, ' private handleTextChanged'); +const compiled = ts.transpileModule(source, { compilerOptions: { + target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS +} }).outputText; +const exported = {}; +new Function('require', 'exports', 'setInterval', 'clearInterval', compiled)( + () => ({ RemoteLogger: { info() {} } }), exported, () => 1, () => {}); +const { StreamingMarkdownContent } = exported; +function active(text, key) { + const view = new StreamingMarkdownContent(); + view.text = text; view.active = true; view.streamKey = key; + view.aboutToAppear(); view.renderedText = text; + return view; +} +test('authoritative rewrite, truncation and deletion replace revealed content while active', () => { + for (const next of ['corrected', 'old', '']) { + const view = active('old content', `rewrite-${next}`); + view.text = next; view.handleTextChanged(); + assert.equal(view.renderedText, next); assert.equal(view.targetText, next); + assert.equal(view.timerId, 0); + } +}); +test('append-only growth keeps the current reveal and animates toward the new target', () => { + const view = active('prefix', 'append'); + view.text = 'prefix suffix'; view.handleTextChanged(); + assert.equal(view.renderedText, 'prefix'); assert.equal(view.targetText, 'prefix suffix'); + assert.notEqual(view.timerId, 0); +}); +test('remount accepts cached prefixes but never resurrects incompatible cached content', () => { + const old = active('obsolete answer', 'remount'); old.aboutToDisappear(); + const corrected = new StreamingMarkdownContent(); + corrected.active = true; corrected.streamKey = 'remount'; corrected.text = 'new answer'; + corrected.aboutToAppear(); + assert.equal(corrected.renderedText, ''); assert.equal(corrected.targetText, 'new answer'); + const prefix = active('new', 'prefix-cache'); prefix.aboutToDisappear(); + const growing = new StreamingMarkdownContent(); + growing.active = true; growing.streamKey = 'prefix-cache'; growing.text = 'new answer'; growing.aboutToAppear(); + assert.equal(growing.renderedText, 'new'); +}); diff --git a/src/apps/mobile/harmonyos/tools/tests/workspace-refresh-race.test.cjs b/src/apps/mobile/harmonyos/tools/tests/workspace-refresh-race.test.cjs index afc5e8206a..fa9e7ffa2d 100644 --- a/src/apps/mobile/harmonyos/tools/tests/workspace-refresh-race.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/workspace-refresh-race.test.cjs @@ -6,7 +6,8 @@ const ts = require('typescript'); const source = fs.readFileSync(path.join(__dirname, '../../entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets'), 'utf8'); const js = ts.transpileModule(source, {compilerOptions: {target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS}}).outputText; const exported = {}; -new Function('require', 'exports', js)(name => name.endsWith('RemoteLogger') ? {RemoteLogger: {info(){}, warn(){}}} : {}, exported); +new Function('require', 'exports', js)(name => name.endsWith('RemoteLogger') ? {RemoteLogger: {info(){}, warn(){}}} : + name.endsWith('RemoteI18n') ? {RemoteI18n: {t: key => key}} : {}, exported); function deferred() { let resolve, reject; const promise = new Promise((yes, no) => {resolve=yes; reject=no;}); @@ -22,6 +23,33 @@ function fixture() { }, {remoteTargetId:()=>target, onCatalogLoading(){}, onCatalogLoaded(){}, onCatalogFailed(){}, onConnectionFailure:error=>errors.push(error)}); return {vm,state,requests,errors,catalogs,target(value){target=value;}}; } + +for (const picker of ['toggleRecentWorkspaces', 'toggleAssistants']) { + for (const backgroundFirst of [true, false]) { + test(`${picker} and background catalog finish independently (background first=${backgroundFirst})`, async () => { + const saved = deferred(), picked = deferred(); + const state = { savedConnections: [], savedConnectionsTargetId: 'a', busy: false, + setWorkspacePickerVisible(value) { this.showWorkspacePicker = value; }, + setAssistantPickerVisible(value) { this.showAssistantPicker = value; }, + setRecentWorkspaces(value) { this.recentWorkspaces = value; }, + setAssistants(value) { this.assistants = value; } }; + let catalogState = 'idle'; + const vm = new exported.RemoteWorkspaceViewModel(state, { + savedConnections: () => saved.promise, + workspaceCatalog: async () => ({workspaces: [{path:'/project'}], recentWorkspaces: [], source:'opened'}), + recentWorkspaces: () => picked.promise, assistants: () => picked.promise + }, {remoteTargetId: () => 'a', isRemoteAvailable: () => true, + onBusy: value => {state.busy = value;}, onStatus() {}, onConnectionFailure(error) {throw error;}, + onCatalogLoading() {catalogState='loading';}, onCatalogLoaded() {catalogState='ready';}, onCatalogFailed() {catalogState='failed';}}); + const first = backgroundFirst ? vm.loadRecentWorkspacesInBackground() : vm[picker](); + const second = backgroundFirst ? vm[picker]() : vm.loadRecentWorkspacesInBackground(); + saved.resolve([]); picked.resolve([{path:'/project',name:'Project'}]); + await Promise.all([first, second]); + assert.equal(catalogState, 'ready', 'opening a picker must not strand the sidebar in loading'); + assert.equal(state.busy, false, 'a host hint must not prevent the picker from releasing busy'); + }); + } +} test('refresh keeps current locations visible and an older response cannot replace the latest result', async()=>{ const f=fixture(); const old=f.vm.loadRecentWorkspacesInBackground(); assert.equal(f.state.savedConnections[0].id,'existing'); From 6ff25f31f267ac938678e12bb18698232300b8c3 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Tue, 22 Sep 2026 19:57:15 +0800 Subject: [PATCH 5/6] fix(android): keep the composer editable while a session hydrates A submitted draft now survives activity recreation through rememberSaveable, so restoring the process cannot resurrect text the user already sent. The draft field stays editable while a newly opened session hydrates, while send and attachment actions remain guarded by busy. Stopping requires a connected host, session rows stay tappable so a slow transcript can be superseded, and the compact drawer paints its scrim with the semantic scrim token rather than the page background, which made opening the sidebar look like the detail page had disappeared. Co-authored-by: OpenBitFun <318544290+bitfun-ai@users.noreply.github.com> --- .../mobile/app/ConversationViewTest.kt | 42 +++++++++++++++++++ .../mobile/app/ui/chat/ComposerBar.kt | 8 +++- .../mobile/app/ui/chat/ConversationView.kt | 9 +++- .../mobile/app/ui/remote/PairingScreen.kt | 2 +- .../app/ui/remote/RemoteSessionListView.kt | 5 ++- .../app/ui/shell/OpenBitFunCompactDrawer.kt | 6 ++- 6 files changed, 65 insertions(+), 7 deletions(-) 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 713e7ffe24..efbd6f386d 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 @@ -354,6 +354,17 @@ class ConversationViewTest { ) } + @Test + fun composerRemainsEditableWhileNewSessionHydrates() { + val intents = mutableListOf() + val state = mutableStateOf(readyState(sessionId = "pending", draft = "").copy(busy = true, timeline = null)) + + setConversationContent(state = { state.value }, onIntent = { intents += it }) + + composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).performTextReplacement("draft during load") + assertEquals(listOf(RemoteSessionIntent.UpdateDraft("draft during load")), intents) + } + @Test fun composerFollowsStoreDraftUpdatesWithinTheSameSession() { val state = mutableStateOf(readyState(sessionId = "s-code", draft = "first")) @@ -403,6 +414,37 @@ class ConversationViewTest { composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).assertTextEquals("send me") } + @Test + fun submittedDraftStaysClearedAcrossRecreationWhileAwaitingAck() { + val restoration = androidx.compose.ui.test.junit4.StateRestorationTester(composeRule) + val state = mutableStateOf(readyState(sessionId = "s-code", draft = "send me")) + restoration.setContent { + OpenBitFunTheme(dark = false) { + ConversationView( + state = state.value, phase = ConnectionPhase.CONNECTED, + settingsPlacement = SettingsPlacement(SettingsPlacementMode.BOTTOM, 0, 0, 0), + onBack = {}, onIntent = { intent -> + state.value = when (intent) { + is RemoteSessionIntent.UpdateDraft -> state.value.copy(draft = intent.text) + else -> state.value.copy(busy = true) + } + }, contextTitle = "Test desktop", onOpenFile = { _, _ -> }, + previewingRemotePath = "", previewLoading = false, + download = RemoteFileDownloadUiState.None, onDownloadFile = { _, _ -> }, + modifier = Modifier.fillMaxSize(), + ) + } + } + composeRule.onNodeWithTag(COMPOSER_SEND_TEST_TAG).performClick() + restoration.emulateSavedInstanceStateRestore() + composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).assert( + SemanticsMatcher.expectValue(SemanticsProperties.EditableText, AnnotatedString("")), + ) + composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).performTextReplacement("next draft") + composeRule.runOnIdle { state.value = state.value.copy(busy = false) } + composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).assertTextEquals("next draft") + } + @Test fun emptyStateShowsInvitationCopy() { composeRule.setContent { 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 e190e30ba9..dca0b67895 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 @@ -125,6 +125,8 @@ internal fun ComposerBar( draft: String, images: List, busy: Boolean, + /** The draft may be edited while a newly opened session is hydrating. */ + inputEnabled: Boolean = !busy, streaming: Boolean, phase: ConnectionPhase, model: ModelOption?, @@ -273,7 +275,7 @@ internal fun ComposerBar( } ComposerField( draft = draft, - enabled = !busy, + enabled = inputEnabled, expanded = expanded, placeholder = placeholder, onDraftChange = onDraftChange, @@ -290,6 +292,7 @@ internal fun ComposerBar( } PrimaryActionButton( action = action, + stopEnabled = ChatComposerPolicy.canStop(streaming, capabilities.requiresRemoteConnection, phase), onVoice = onVoice, onSend = onSend, onStop = onStop, @@ -669,13 +672,14 @@ private const val DimmedAlpha: Float = 0.38f @Composable private fun PrimaryActionButton( action: ComposerPrimaryAction, + stopEnabled: Boolean = true, onVoice: () -> Unit, onSend: () -> Unit, onStop: () -> Unit, testTag: String = COMPOSER_SEND_TEST_TAG, ) { val colors = MaterialTheme.colorScheme - val enabled = action == ComposerPrimaryAction.STOP || + val enabled = (action == ComposerPrimaryAction.STOP && stopEnabled) || action == ComposerPrimaryAction.SEND || action == ComposerPrimaryAction.VOICE val description = when (action) { diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt index 7fa2d64cb5..594ee827f8 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt @@ -183,7 +183,7 @@ internal fun ConversationView( // The remote composer's single source of truth is the store's draft. Typing, // voice, and send all round-trip through `state.draft` so a half-written // message survives session switches and process restarts via DraftStore. - var submittedDraft by remember(attachmentOwner, state.selectedSessionId) { mutableStateOf(null) } + var submittedDraft by rememberSaveable(attachmentOwner, state.selectedSessionId) { mutableStateOf(null) } val draft = if (submittedDraft == state.draft) "" else state.draft val focusManager = LocalFocusManager.current val keyboard = LocalSoftwareKeyboardController.current @@ -305,7 +305,7 @@ internal fun ConversationView( ConversationHeader( title = state.sessions.firstOrNull { it.id == sessionId }?.title.orEmpty(), contextTitle = contextTitle, - canStop = activeTurn != null, + canStop = activeTurn != null && phase == ConnectionPhase.CONNECTED, enabled = !state.busy && sessionId.isNotEmpty(), onBack = onBack, onOpenSidebar = onOpenSidebar, @@ -348,6 +348,11 @@ internal fun ConversationView( images = images, // An empty session id would send nowhere, so it reads as busy. busy = state.busy || preparingImage || attachmentsBlocked || sessionId.isEmpty(), + // Session hydration must not make the draft field require + // repeated taps. Sending and attachment actions remain + // guarded by `busy`; typing can start as soon as a session + // has been selected and the draft survives hydration. + inputEnabled = sessionId.isNotEmpty() && !preparingImage && !attachmentsBlocked, streaming = activeTurn != null, phase = phase, model = timeline?.selectedModelOption(stringResource(R.string.models_unnamed)), diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt index 183d6f1298..6ba4534fe9 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt @@ -266,7 +266,7 @@ internal fun RemoteCompactHome( ) { Text(stringResource(R.string.home_recent_all), fontSize = 12.sp) } } recent.forEach { session -> - Column(Modifier.fillMaxWidth().clickable(enabled = !ready!!.busy) { onOpen(session.id) } + Column(Modifier.fillMaxWidth().clickable { onOpen(session.id) } .padding(vertical = MobileDesignGeometry.RecentHomeRowPadding)) { Text(session.title, fontSize = 15.sp, maxLines = 2) val workspace = session.workspaceName?.takeIf { it.isNotBlank() } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/RemoteSessionListView.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/RemoteSessionListView.kt index 1442db0168..bc63f6d45e 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/RemoteSessionListView.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/RemoteSessionListView.kt @@ -297,7 +297,10 @@ internal fun RemoteSessionListContent( settings = viewSettings, projectChild = section is SessionListSection.Project, selected = session.id == state.selectedSessionId, - enabled = !state.busy, + // Opening a session is cancellable/supersedable + // in the store; keep rows tappable while the + // previous transcript hydrates. + enabled = true, onOpen = { onIntent(RemoteSessionIntent.Open(session.id)) onOpen(session.id) diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/OpenBitFunCompactDrawer.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/OpenBitFunCompactDrawer.kt index 2a7a8589e0..694548de6b 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/OpenBitFunCompactDrawer.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/OpenBitFunCompactDrawer.kt @@ -283,7 +283,11 @@ internal fun OpenBitFunCompactDrawer( Box( Modifier .fillMaxSize() - .background(androidx.compose.material3.MaterialTheme.colorScheme.background) + // Use the semantic scrim token. Using the page + // background here makes a light-theme drawer paint a + // white sheet over the entire detail page, so opening + // the sidebar looks like the page disappeared. + .background(androidx.compose.material3.MaterialTheme.colorScheme.scrim) .graphicsLayer { alpha = scrimProgress.value } .clickable( enabled = open, From cba2a7456abdc3637cc02d1d1380f7ccc9381c1a Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Tue, 22 Sep 2026 19:57:18 +0800 Subject: [PATCH 6/6] fix(ios): settle the remote composer draft on the revision it cleared A remote send now records the draft revision it cleared, so an acknowledgement that arrives after the user typed again no longer wipes the newer text, and a send only settles while that revision is unchanged. Streaming text state treats host rewrites and deletions as authoritative instead of discarding them as stale chunks, stopping a turn requires a connected remote host, and the transcript path gains DEBUG-only timing so a slow open or apply is attributable. Co-authored-by: OpenBitFun <318544290+bitfun-ai@users.noreply.github.com> --- .../Features/Chat/ChatTimelineView.swift | 23 +++++++++++-- .../Features/Chat/ComposerBar.swift | 4 ++- .../Features/Chat/ConversationHeader.swift | 2 +- .../MobileAppModel+RemoteSession.swift | 32 ++++++++++++++++--- .../Infrastructure/MobileAppModel.swift | 16 +++++++--- .../Infrastructure/RemoteAuthorityGate.swift | 4 +-- .../Infrastructure/StreamingTextState.swift | 9 +++--- .../Models/MobilePresentationModels.swift | 1 + .../Testing/RemoteAuthorityGateTests.swift | 8 +++-- .../ios/Testing/StreamingTextStateTests.swift | 14 +++++++- 10 files changed, 90 insertions(+), 23 deletions(-) diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift index 77ff3135ac..9af62f1190 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift @@ -4,6 +4,11 @@ import OpenBitFunMobileCore import SwiftUI import UIKit +private let timelinePerfLog = Logger( + subsystem: "com.openbitfun.mobile.ios", + category: "performance" +) + struct ChatTimelineView: View { @ObservedObject var model: MobileAppModel var onLoadOlderMessages: (() -> Void)? = nil @@ -14,8 +19,22 @@ struct ChatTimelineView: View { var bottomOverlayInset: CGFloat = 0 @StateObject private var scrollController = TimelineScrollController() - var body: some View { - ScrollViewReader { _ in + var body: some View { timelineContent() } + + /// Temporary perf scaffolding: the transcript's view graph is built eagerly, so + /// one state update costs one full pass over the loaded rows. + private func timelineContent() -> some View { + #if DEBUG + let renderStartedAt = ProcessInfo.processInfo.systemUptime + defer { + let milliseconds = Int((ProcessInfo.processInfo.systemUptime - renderStartedAt) * 1_000) + let blocks = model.timelineRows.reduce(0) { $0 + $1.blocks.count } + timelinePerfLog.info( + "Timeline body render rows=\(model.timelineRows.count, privacy: .public) blocks=\(blocks, privacy: .public) ms=\(milliseconds, privacy: .public)" + ) + } + #endif + return ScrollViewReader { _ in ScrollView(showsIndicators: false) { VStack(spacing: MobileDesignGeometry.messageSpacing) { // History is already paged by the session store. Measure the diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift index 1ca65133b4..0becc2d0af 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift @@ -348,7 +348,9 @@ struct ComposerBar: View { .clipShape(Circle()) } .buttonStyle(.plain) - .disabled(action == .sendBlocked || action == .voiceBlocked) + .disabled(action == .sendBlocked || action == .voiceBlocked || + (action == .stopTurn && model.surface == .remote && + (!model.remoteConnected || model.connectionPhase != .connected))) .accessibilityIdentifier("composer.primaryAction") .accessibilityLabel(primaryActionLabel) } diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ConversationHeader.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ConversationHeader.swift index 1fac387b84..43b48292fb 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ConversationHeader.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ConversationHeader.swift @@ -200,7 +200,7 @@ struct ConversationActionsPopover: View { .frame(height: 28) .padding(.leading, 8) action("已上传文件", icon: "cloud", perform: model.showUploadedFiles) - if model.isSending { + if model.isSending && model.remoteConnected && model.connectionPhase == .connected { Divider().overlay(OpenBitFunTheme.line).padding(.vertical, 8) action("停止", icon: "gearshape", perform: model.stopSending) } diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift index 8d6a356cdf..e2dff7af7c 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift @@ -867,12 +867,14 @@ extension MobileAppModel { let coreAdapter else { return false } mobilePerformanceLog.info("Composer send accepted characters=\(value.count) rows=\(self.timelineRows.count) user_rows=\(self.timelineRows.filter { $0.kind == "USER" }.count) generation=\(self.composerSendGeneration)") let images = composerImages - pendingComposerSend = PendingComposerSend( - sessionID: sessionID, text: draft, images: images, - previousAckID: lastAppliedRemoteSendID - ) + let submittedText = draft draft = "" composerImages = [] + pendingComposerSend = PendingComposerSend( + sessionID: sessionID, text: submittedText, images: images, + previousAckID: lastAppliedRemoteSendID, + clearedDraftRevision: composerDraftRevision + ) composerSendGeneration &+= 1 isSending = true busy = true @@ -890,7 +892,8 @@ extension MobileAppModel { if ComposerSendSettlementPolicy.shouldRestore( sentSession: pending.sessionID, currentSession: selectedSessionID, acknowledged: succeeded, draftIsEmpty: draft.isEmpty, - attachmentsAreEmpty: composerImages.isEmpty + attachmentsAreEmpty: composerImages.isEmpty, + draftUnchanged: composerDraftRevision == pending.clearedDraftRevision ) { draft = pending.text composerImages = pending.images @@ -1106,13 +1109,32 @@ extension MobileAppModel { } setPublishedIfChanged(\.modelOptions, to: projectedModelOptions) if acceptsTimeline, let timeline = ready.timeline { + #if DEBUG + let applyStartedAt = ProcessInfo.processInfo.systemUptime + #endif + let wasUnconfirmed = remoteTranscriptUnconfirmed setPublishedIfChanged(\.remoteTranscriptUnconfirmed, to: timeline.origin != .host) let projectedRows = MobileConversationRow.reconcile( timeline.conversationRows().map(Self.mapConversationRow), with: timelineRows) + #if DEBUG + if wasUnconfirmed != (timeline.origin != .host) { + let openMS = remoteConversationOpenStartedAt.map { Int((ProcessInfo.processInfo.systemUptime - $0) * 1_000) } ?? -1 + mobilePerformanceLog.info( + "Timeline origin changed origin=\(timeline.origin == .host ? "host" : "cache", privacy: .public) rows=\(projectedRows.count, privacy: .public) persisted=\(timeline.persistedMessages.count, privacy: .public) since_open_ms=\(openMS, privacy: .public)" + ) + } + #endif if timelineRows != projectedRows { 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 + #if DEBUG + let applyMS = Int((ProcessInfo.processInfo.systemUptime - applyStartedAt) * 1_000) + let openMS = remoteConversationOpenStartedAt.map { Int((ProcessInfo.processInfo.systemUptime - $0) * 1_000) } ?? -1 + mobilePerformanceLog.info( + "Timeline apply origin=\(timeline.origin == .host ? "host" : "cache", privacy: .public) rows=\(projectedRows.count, privacy: .public) blocks=\(projectedRows.reduce(0) { $0 + $1.blocks.count }, privacy: .public) persisted=\(timeline.persistedMessages.count, privacy: .public) ui_ms=\(applyMS, privacy: .public) since_open_ms=\(openMS, privacy: .public)" + ) + #endif 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) { diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift index e881a9e27f..c1f067e9ea 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift @@ -37,7 +37,9 @@ final class MobileAppModel: ObservableObject { @Published var remoteCreateSubmitting = false @Published var remoteCreateError: String? @Published var remoteCreateDeviceError: String? - @Published var selectedSessionID: String + @Published var selectedSessionID: String { + didSet { if selectedSessionID != oldValue { composerDraftRevision &+= 1 } } + } @Published var messages: [ChatMessage] @Published private var renderedTimelineRows: [MobileConversationRow] = [] var timelineRows: [MobileConversationRow] { @@ -47,7 +49,11 @@ final class MobileAppModel: ObservableObject { if next != renderedTimelineRows { renderedTimelineRows = next } } } - @Published var draft = "" + @Published var draft = "" { + didSet { if draft != oldValue { composerDraftRevision &+= 1 } } + } + // Includes edits later erased and session round trips, not just current contents. + var composerDraftRevision: UInt64 = 0 var lastAppliedRemoteSendID: String? var pendingComposerSend: PendingComposerSend? @Published var composerSendGeneration: UInt64 = 0 @@ -59,7 +65,9 @@ final class MobileAppModel: ObservableObject { @Published var connectionPhase: ConnectionPhase = .connected @Published var isSending = false @Published var busy = false - @Published var composerImages: [ComposerAttachment] = [] + @Published var composerImages: [ComposerAttachment] = [] { + didSet { if composerImages != oldValue { composerDraftRevision &+= 1 } } + } @Published var modelOptions: [ComposerModelOption] = [] @Published var toastMessage: String? @Published var remoteConnected = false @@ -409,7 +417,7 @@ final class MobileAppModel: ObservableObject { } func stopSending() { - guard remoteSessionSelected else { return } + guard remoteSessionSelected, remoteConnected, connectionPhase == .connected else { return } coreAdapter?.cancelRemoteTurn(sessionID: selectedSessionID, turnID: activeTurnID) } diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/RemoteAuthorityGate.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/RemoteAuthorityGate.swift index f2b87e786a..0810f959ce 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/RemoteAuthorityGate.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/RemoteAuthorityGate.swift @@ -287,9 +287,9 @@ enum RemoteAuthorityGate { enum ComposerSendSettlementPolicy { static func shouldRestore( sentSession: String, currentSession: String, - acknowledged: Bool, draftIsEmpty: Bool, attachmentsAreEmpty: Bool + acknowledged: Bool, draftIsEmpty: Bool, attachmentsAreEmpty: Bool, draftUnchanged: Bool ) -> Bool { - !acknowledged && sentSession == currentSession && draftIsEmpty && attachmentsAreEmpty + !acknowledged && sentSession == currentSession && draftUnchanged && draftIsEmpty && attachmentsAreEmpty } } diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/StreamingTextState.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/StreamingTextState.swift index b71f3f2aae..3949567a86 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/StreamingTextState.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/StreamingTextState.swift @@ -12,11 +12,10 @@ struct StreamingTextState: Equatable { } mutating func update(_ text: String, active: Bool) { - // A shorter prefix while streaming is a delayed snapshot, not a new target. - // Final snapshots remain authoritative even when they remove text. - if active, target.hasPrefix(text), text.count < target.count { return } - // Non-prefix edits are authoritative corrections. - guard active, text.hasPrefix(visible) else { + // The transcript owner already resolves record revisions. Rewrites and + // deletions are authoritative even while active; only append-only growth + // may keep animating from the previous reveal buffer. + guard active, text.hasPrefix(target), text.hasPrefix(visible) else { visible = text target = text ticksRemaining = 0 diff --git a/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift b/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift index 30ae5a26a4..bb1a51268c 100644 --- a/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift +++ b/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift @@ -545,4 +545,5 @@ struct PendingComposerSend { let text: String let images: [ComposerAttachment] let previousAckID: String? + let clearedDraftRevision: UInt64 } diff --git a/src/apps/mobile/ios/Testing/RemoteAuthorityGateTests.swift b/src/apps/mobile/ios/Testing/RemoteAuthorityGateTests.swift index 8f27ba5c0c..2029325d04 100644 --- a/src/apps/mobile/ios/Testing/RemoteAuthorityGateTests.swift +++ b/src/apps/mobile/ios/Testing/RemoteAuthorityGateTests.swift @@ -603,7 +603,7 @@ struct RemoteAuthorityGateTests { expect(ComposerSendSettlementPolicy.shouldRestore( sentSession: "a", currentSession: "a", acknowledged: false, - draftIsEmpty: true, attachmentsAreEmpty: true + draftIsEmpty: true, attachmentsAreEmpty: true, draftUnchanged: true ), "failed send restores the cleared composer") for (session, ack, emptyDraft, emptyImages) in [ ("a", true, true, true), ("b", false, true, true), @@ -611,9 +611,13 @@ struct RemoteAuthorityGateTests { ] { expect(!ComposerSendSettlementPolicy.shouldRestore( sentSession: "a", currentSession: session, acknowledged: ack, - draftIsEmpty: emptyDraft, attachmentsAreEmpty: emptyImages + draftIsEmpty: emptyDraft, attachmentsAreEmpty: emptyImages, draftUnchanged: true ), "send settlement preserves newer typing, attachments and another session") } + expect(!ComposerSendSettlementPolicy.shouldRestore( + sentSession: "a", currentSession: "a", acknowledged: false, + draftIsEmpty: true, attachmentsAreEmpty: true, draftUnchanged: false + ), "edits later erased, removed attachments and session round trips invalidate restoration") for reason in ["NETWORK", "TIMEOUT", "TRANSPORT"] { expect(RemoteSessionFailureProjectionPolicy.keepsVisibleConversation(reasonName: reason), "a retryable transport failure keeps the rendered conversation") diff --git a/src/apps/mobile/ios/Testing/StreamingTextStateTests.swift b/src/apps/mobile/ios/Testing/StreamingTextStateTests.swift index 86ae3696ed..bde426b1f8 100644 --- a/src/apps/mobile/ios/Testing/StreamingTextStateTests.swift +++ b/src/apps/mobile/ios/Testing/StreamingTextStateTests.swift @@ -16,7 +16,7 @@ struct StreamingTextStateTests { } assert(state.visible == text) state.update("中文", active: true) - assert(state.visible == text && state.target == text) + assert(state.visible == "中文" && state.target == "中文" && state.ticksRemaining == 0) state.update(text + " tail", active: true) state.advance() state.update("corrected", active: true) @@ -37,6 +37,18 @@ struct StreamingTextStateTests { assert(resumed.visible == "partial") resumed.advance() assert(resumed.visible.hasPrefix("partial")) + // A same-row cached reveal must not resurrect content removed by the host. + var shortened = StreamingTextState(text: "partial obsolete") + shortened.update("partial", active: true) + assert(shortened.visible == "partial" && shortened.target == "partial") + shortened.update("", active: true) + assert(shortened.visible.isEmpty && shortened.ticksRemaining == 0) + // Corrections may still share the visible prefix while changing the + // unrevealed target; the old animation must not remain in flight. + var pending = StreamingTextState(text: "prefix") + pending.update("prefix obsolete", active: true) + pending.update("prefix fixed", active: true) + assert(pending.visible == "prefix fixed" && pending.ticksRemaining == 0) print("Streaming text state tests passed") } }