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/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..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 @@ -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" @@ -182,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 @@ -304,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, @@ -347,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)), @@ -444,6 +450,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/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, 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/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'); diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift index c910b75ef8..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 @@ -23,6 +42,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/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 d92212fe5e..e2dff7af7c 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() { @@ -861,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 @@ -884,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 @@ -1100,12 +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) { @@ -1125,8 +1154,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..c1f067e9ea 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" @@ -33,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] { @@ -43,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 @@ -55,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 @@ -405,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") } } 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/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/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..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 @@ -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 @@ -92,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() { @@ -105,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 @@ -113,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 } @@ -944,6 +969,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 +1110,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 +1126,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) @@ -1149,6 +1188,7 @@ public class RemoteSessionStore internal constructor( else -> ChatSyncPhase.IDLE }) if (caughtUp && !replayingHistory) publishDurableTimeline() + if (status != "running" && status != "streaming" && catalogDirty) refreshCatalog() } } } @@ -1581,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 @@ -1753,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)) } @@ -1985,6 +2025,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/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/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) } 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 880985db58..b9435c5d22 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" @@ -314,9 +319,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 @@ -324,12 +338,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() } diff --git a/src/crates/assembly/core/AGENTS.md b/src/crates/assembly/core/AGENTS.md index f11ecf0e93..202974e247 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. + For built-in provider overlay, trusted endpoint validation, and reasoning catalog changes: ```bash diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 8b68be6038..5fabcbb041 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 @@ -17113,6 +17206,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(),