From e1152cde1921b0e6af73109f4839067f61f37b6c Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Mon, 14 Sep 2026 19:01:46 +0800 Subject: [PATCH] fix(mobile): align iOS remote conversations with HarmonyOS --- src/apps/mobile/AGENTS.md | 5 + .../ios/OpenBitFun.xcodeproj/project.pbxproj | 14 +- .../App/MobileLaunchConfiguration.swift | 10 +- .../ios/OpenBitFun/App/OpenBitFunApp.swift | 6 +- .../Features/Chat/ChatTimelineView.swift | 479 ++++++++++++++---- .../Features/Chat/ComposerBar.swift | 15 +- .../Chat/TimelineScrollController.swift | 317 ++++++++++++ .../DesignSystem/MobileDesignGallery.swift | 187 +++++++ .../Features/Remote/RemoteHomeViews.swift | 122 +++-- .../Features/Shell/MobileShellView.swift | 10 +- .../Shell/RemoteFilePreviewView.swift | 12 +- .../Features/Shell/SidebarView.swift | 53 +- .../MobileAppModel+FilePreview.swift | 37 +- .../MobileAppModel+GeneralChat.swift | 18 +- .../MobileAppModel+RemoteSession.swift | 112 +++- .../Infrastructure/MobileAppModel.swift | 20 +- .../Infrastructure/MobileCoreAdapter.swift | 9 + .../Infrastructure/RemoteAuthorityGate.swift | 20 + .../RemoteHomePresentation.swift | 12 + .../Infrastructure/StreamingTextState.swift | 60 +++ .../Models/MobilePresentationModels.swift | 183 ++++++- .../Resources/Localizable.xcstrings | 30 ++ .../RemoteCodeSessionSendUITests.swift | 424 ++++++++++++++++ .../ios/Testing/MobileProcessGroupTests.swift | 96 ++++ .../Testing/RemoteAuthorityGateTests.swift | 50 ++ .../Testing/RemoteHomePresentationTests.swift | 33 ++ .../ios/Testing/StreamingTextStateTests.swift | 42 ++ .../ios/Testing/run-pure-swift-tests.sh | 19 + .../mobile/core/domain/ChatTimelineStore.kt | 115 ++++- .../mobile/core/domain/ToolInputPolicy.kt | 11 + .../core/domain/ChatTimelineStoreTest.kt | 125 +++++ .../session/ConversationPresentation.kt | 3 + .../session/MessageBlockPresentation.kt | 109 ++-- .../feature/session/RemoteSessionStore.kt | 58 ++- .../feature/workspace/RemoteWorkspaceStore.kt | 32 +- .../session/MessageBlockPresentationTest.kt | 116 ++++- .../feature/session/RemoteSessionStoreTest.kt | 153 ++++++ .../workspace/RemoteWorkspaceStoreTest.kt | 87 ++++ 38 files changed, 2929 insertions(+), 275 deletions(-) create mode 100644 src/apps/mobile/ios/OpenBitFun/Features/Chat/TimelineScrollController.swift create mode 100644 src/apps/mobile/ios/OpenBitFun/Infrastructure/RemoteHomePresentation.swift create mode 100644 src/apps/mobile/ios/OpenBitFun/Infrastructure/StreamingTextState.swift create mode 100644 src/apps/mobile/ios/Testing/MobileProcessGroupTests.swift create mode 100644 src/apps/mobile/ios/Testing/RemoteHomePresentationTests.swift create mode 100644 src/apps/mobile/ios/Testing/StreamingTextStateTests.swift diff --git a/src/apps/mobile/AGENTS.md b/src/apps/mobile/AGENTS.md index a1ea77f6aa..8a8a1e89c3 100644 --- a/src/apps/mobile/AGENTS.md +++ b/src/apps/mobile/AGENTS.md @@ -76,6 +76,11 @@ UiState or an Intent declared there, and no module above it is visible to them. `./gradlew compileKotlinIosSimulatorArm64` for iOS. Run iOS pure Swift infrastructure checks from the repository root with `(cd src/apps/mobile/ios && ./Testing/run-pure-swift-tests.sh)`. + The isolated native streaming fixture (no remote requests) is covered by + `xcodebuild -project src/apps/mobile/ios/OpenBitFun.xcodeproj -scheme OpenBitFun -configuration Debug -destination 'platform=iOS Simulator,name=iPhone 16 Plus' -parallel-testing-enabled NO -only-testing:OpenBitFunUITests/StreamingPresentationUITests test`. + Run it on one phone and one iPad destination when changing timeline layout, + streaming reveal, keyboard dismissal, or history anchoring. The Debug-only + launch argument `--streaming-regression` opens the same deterministic fixture. `local.properties` holds the local SDK path and is not committed. - The Android app builds from `android/`: `./gradlew :app:assembleDebug` and diff --git a/src/apps/mobile/ios/OpenBitFun.xcodeproj/project.pbxproj b/src/apps/mobile/ios/OpenBitFun.xcodeproj/project.pbxproj index 2ddf9e279c..9ccdfd1fe3 100644 --- a/src/apps/mobile/ios/OpenBitFun.xcodeproj/project.pbxproj +++ b/src/apps/mobile/ios/OpenBitFun.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ A10000000000000000000004 /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000004 /* SidebarView.swift */; }; A10000000000000000000005 /* ConversationHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000005 /* ConversationHeader.swift */; }; A10000000000000000000006 /* ChatTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000006 /* ChatTimelineView.swift */; }; + A90000000000000000000004 /* TimelineScrollController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90000000000000000000004 /* TimelineScrollController.swift */; }; A10000000000000000000007 /* ComposerBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000007 /* ComposerBar.swift */; }; A10000000000000000000008 /* MobileShellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000008 /* MobileShellView.swift */; }; A10000000000000000000010 /* MobileCoreAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000010 /* MobileCoreAdapter.swift */; }; @@ -48,6 +49,8 @@ A10000000000000000000040 /* SpeechInputController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000040; }; A10000000000000000000041 = {isa = PBXBuildFile; fileRef = B10000000000000000000041; }; A91000000000000000000001 = {isa = PBXBuildFile; fileRef = B91000000000000000000001; }; + A90000000000000000000002 /* RemoteHomePresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90000000000000000000002 /* RemoteHomePresentation.swift */; }; + A90000000000000000000003 /* StreamingTextState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90000000000000000000003 /* StreamingTextState.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -63,6 +66,7 @@ B10000000000000000000004 /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = ""; }; B10000000000000000000005 /* ConversationHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationHeader.swift; sourceTree = ""; }; B10000000000000000000006 /* ChatTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTimelineView.swift; sourceTree = ""; }; + B90000000000000000000004 /* TimelineScrollController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineScrollController.swift; sourceTree = ""; }; B10000000000000000000007 /* ComposerBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerBar.swift; sourceTree = ""; }; B10000000000000000000008 /* MobileShellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileShellView.swift; sourceTree = ""; }; B10000000000000000000010 /* MobileCoreAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileCoreAdapter.swift; sourceTree = ""; }; @@ -99,6 +103,8 @@ B10000000000000000000041 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskCompletionNotifier.swift; sourceTree = ""; }; B91000000000000000000001 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenBitFun/Features/Shell/MiniAppsView.swift; sourceTree = SOURCE_ROOT; }; B91000000000000000000002 = {isa = PBXFileReference; lastKnownFileType = folder; path = OpenBitFun/Resources/MiniApps; sourceTree = SOURCE_ROOT; }; + B90000000000000000000002 /* RemoteHomePresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteHomePresentation.swift; sourceTree = ""; }; + B90000000000000000000003 /* StreamingTextState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamingTextState.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -115,7 +121,7 @@ D10000000000000000000013 /* Settings */ = {isa = PBXGroup; children = (B10000000000000000000032 /* AppSettingsView.swift */); path = Settings; sourceTree = ""; }; D10000000000000000000014 /* Pairing */ = {isa = PBXGroup; children = (B10000000000000000000033 /* PairingSheet.swift */); path = Pairing; sourceTree = ""; }; D10000000000000000000015 /* Account */ = {isa = PBXGroup; children = (B10000000000000000000035 /* AccountSettingsView.swift */); path = Account; sourceTree = ""; }; - D10000000000000000000004 /* Chat */ = {isa = PBXGroup; children = (B10000000000000000000030 /* ConversationHomeViews.swift */, B10000000000000000000005 /* ConversationHeader.swift */, B10000000000000000000006 /* ChatTimelineView.swift */, B10000000000000000000007 /* ComposerBar.swift */); path = Chat; sourceTree = ""; }; + D10000000000000000000004 /* Chat */ = {isa = PBXGroup; children = (B90000000000000000000004 /* TimelineScrollController.swift */, B10000000000000000000030 /* ConversationHomeViews.swift */, B10000000000000000000005 /* ConversationHeader.swift */, B10000000000000000000006 /* ChatTimelineView.swift */, B10000000000000000000007 /* ComposerBar.swift */); path = Chat; sourceTree = ""; }; D10000000000000000000005 /* Shell */ = {isa = PBXGroup; children = (B90000000000000000000001 /* StartupBrandReveal.swift */, B10000000000000000000003 /* OpenBitFunTheme.swift */, B10000000000000000000004 /* SidebarView.swift */, B10000000000000000000008 /* MobileShellView.swift */, B10000000000000000000019 /* SessionActionComponents.swift */, B10000000000000000000020 /* RemoteCreateSessionView.swift */, B10000000000000000000021 /* RemoteFilePreviewView.swift */, B10000000000000000000022 /* RemoteSettingsViews.swift */); path = Shell; sourceTree = ""; }; D10000000000000000000011 /* Resources */ = {isa = PBXGroup; children = (B10000000000000000000016 /* Localizable.xcstrings */); path = Resources; sourceTree = ""; }; D10000000000000000000016 /* Presentation */ = {isa = PBXGroup; children = (D10000000000000000000018 /* Models */); path = Presentation; sourceTree = ""; }; @@ -124,7 +130,7 @@ D10000000000000000000009 /* Products */ = {isa = PBXGroup; children = (B10000000000000000000000 /* OpenBitFun.app */, B20000000000000000000000 /* OpenBitFunUITests.xctest */); name = Products; sourceTree = ""; }; D20000000000000000000001 /* OpenBitFunUITests */ = {isa = PBXGroup; children = (B20000000000000000000001 /* RemoteCodeSessionSendUITests.swift */); path = OpenBitFunUITests; sourceTree = ""; }; D10000000000000000000010 /* DesignSystem */ = {isa = PBXGroup; children = (B10000000000000000000013 /* GeneratedMobileDesignTokens.swift */, B10000000000000000000014 /* GeneratedMobilePreviewScenarios.swift */, B10000000000000000000015 /* MobileDesignGallery.swift */, B10000000000000000000018 /* AdaptiveModalComponents.swift */); path = DesignSystem; sourceTree = ""; }; - D10000000000000000000008 /* Infrastructure */ = {isa = PBXGroup; children = (B10000000000000000000002 /* MobileAppModel.swift */, B10000000000000000000010 /* MobileCoreAdapter.swift */, B10000000000000000000017 /* MobileLocalization.swift */, B10000000000000000000024 /* MobileAppModel+FilePreview.swift */, B10000000000000000000025 /* MobileAppModel+Account.swift */, B10000000000000000000026 /* MobileAppModel+RemoteSession.swift */, B10000000000000000000027 /* MobileAppModel+GeneralChat.swift */, B10000000000000000000028 /* RemoteAuthorityGate.swift */, B10000000000000000000029 /* AccountFailureCopy.swift */, D10000000000000000000017 /* Platform */); path = Infrastructure; sourceTree = ""; }; + D10000000000000000000008 /* Infrastructure */ = {isa = PBXGroup; children = (B90000000000000000000003 /* StreamingTextState.swift */, B90000000000000000000002, B10000000000000000000002 /* MobileAppModel.swift */, B10000000000000000000010 /* MobileCoreAdapter.swift */, B10000000000000000000017 /* MobileLocalization.swift */, B10000000000000000000024 /* MobileAppModel+FilePreview.swift */, B10000000000000000000025 /* MobileAppModel+Account.swift */, B10000000000000000000026 /* MobileAppModel+RemoteSession.swift */, B10000000000000000000027 /* MobileAppModel+GeneralChat.swift */, B10000000000000000000028 /* RemoteAuthorityGate.swift */, B10000000000000000000029 /* AccountFailureCopy.swift */, D10000000000000000000017 /* Platform */); path = Infrastructure; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -140,7 +146,7 @@ C91000000000000000000001 = {isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = (); inputPaths = (); outputPaths = ("$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/MiniApps"); alwaysOutOfDate = 1; runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "export PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\"\nnode \"$SRCROOT/../miniapps/generate.cjs\" ios \"$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/MiniApps\"\n"; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - C10000000000000000000002 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A91000000000000000000001, A90000000000000000000001, A10000000000000000000041, A10000000000000000000040, A10000000000000000000001, A10000000000000000000002, A10000000000000000000003, A10000000000000000000004, A10000000000000000000005, A10000000000000000000006, A10000000000000000000007, A10000000000000000000008, A10000000000000000000010, A10000000000000000000013, A10000000000000000000014, A10000000000000000000015, A10000000000000000000017, A10000000000000000000018, A10000000000000000000019, A10000000000000000000020, A10000000000000000000021, A10000000000000000000022, A10000000000000000000024, A10000000000000000000025, A10000000000000000000026, A10000000000000000000027, A10000000000000000000028, A10000000000000000000029, A10000000000000000000030, A10000000000000000000031, A10000000000000000000032, A10000000000000000000033, A10000000000000000000034, A10000000000000000000035, A10000000000000000000036, A10000000000000000000037, A10000000000000000000038); runOnlyForDeploymentPostprocessing = 0; }; + C10000000000000000000002 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A91000000000000000000001, A90000000000000000000003, A90000000000000000000004, A90000000000000000000002, A90000000000000000000001, A10000000000000000000041, A10000000000000000000040, A10000000000000000000001, A10000000000000000000002, A10000000000000000000003, A10000000000000000000004, A10000000000000000000005, A10000000000000000000006, A10000000000000000000007, A10000000000000000000008, A10000000000000000000010, A10000000000000000000013, A10000000000000000000014, A10000000000000000000015, A10000000000000000000017, A10000000000000000000018, A10000000000000000000019, A10000000000000000000020, A10000000000000000000021, A10000000000000000000022, A10000000000000000000024, A10000000000000000000025, A10000000000000000000026, A10000000000000000000027, A10000000000000000000028, A10000000000000000000029, A10000000000000000000030, A10000000000000000000031, A10000000000000000000032, A10000000000000000000033, A10000000000000000000034, A10000000000000000000035, A10000000000000000000036, A10000000000000000000037, A10000000000000000000038); runOnlyForDeploymentPostprocessing = 0; }; C20000000000000000000002 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A20000000000000000000001 /* RemoteCodeSessionSendUITests.swift in Sources */); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ @@ -156,7 +162,7 @@ /* Begin XCBuildConfiguration section */ F10000000000000000000003 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = {ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; DEVELOPMENT_TEAM = ""; FRAMEWORK_SEARCH_PATHS = ( "$(SRCROOT)/../shared/core-feature/build/XCFrameworks/debug" ); INFOPLIST_FILE = OpenBitFun/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; PRODUCT_BUNDLE_IDENTIFIER = com.openbitfun.mobile.ios; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; F10000000000000000000004 /* Release */ = {isa = XCBuildConfiguration; buildSettings = {ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; DEVELOPMENT_TEAM = ""; FRAMEWORK_SEARCH_PATHS = ( "$(SRCROOT)/../shared/core-feature/build/XCFrameworks/debug" ); INFOPLIST_FILE = OpenBitFun/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; PRODUCT_BUNDLE_IDENTIFIER = com.openbitfun.mobile.ios; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; - F10000000000000000000005 /* Target Debug */ = {isa = XCBuildConfiguration; buildSettings = {SDKROOT = iphoneos; }; name = Debug; }; + F10000000000000000000005 /* Target Debug */ = {isa = XCBuildConfiguration; buildSettings = { SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";SDKROOT = iphoneos; }; name = Debug; }; F10000000000000000000006 /* Target Release */ = {isa = XCBuildConfiguration; buildSettings = {SDKROOT = iphoneos; }; name = Release; }; F20000000000000000000002 /* UI Test Debug */ = {isa = XCBuildConfiguration; buildSettings = {CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; PRODUCT_BUNDLE_IDENTIFIER = com.openbitfun.mobile.ios.ui-tests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = OpenBitFun; }; name = Debug; }; F20000000000000000000003 /* UI Test Release */ = {isa = XCBuildConfiguration; buildSettings = {CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; PRODUCT_BUNDLE_IDENTIFIER = com.openbitfun.mobile.ios.ui-tests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = OpenBitFun; }; name = Release; }; diff --git a/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift b/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift index 62b4953e63..0b2cc4045d 100644 --- a/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift +++ b/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift @@ -2,6 +2,14 @@ import Foundation @MainActor enum MobileLaunchConfiguration { + static var streamingRegressionPreview: Bool { + #if DEBUG + ProcessInfo.processInfo.arguments.contains("--streaming-regression") + #else + false + #endif + } + static var pairingAccountPreview: Bool { ProcessInfo.processInfo.arguments.contains("--pairing-account") } static var pairingManualPreview: Bool { ProcessInfo.processInfo.arguments.contains("--pairing-manual") } static func makeModel() -> MobileAppModel { @@ -13,7 +21,7 @@ enum MobileLaunchConfiguration { ChatMessage(id: UUID(), role: .user, text: "你好"), ChatMessage(id: UUID(), role: .assistant, text: "这是 OpenBitFun 的移动端会话界面。你可以从手机连接桌面端,查看工作区、会话和 Agent 的执行状态。") ], - connectCore: !ProcessInfo.processInfo.arguments.contains("--harness-preview") && designPreviewScenario() == nil + connectCore: !streamingRegressionPreview && !ProcessInfo.processInfo.arguments.contains("--harness-preview") && designPreviewScenario() == nil ) return configure(model) } diff --git a/src/apps/mobile/ios/OpenBitFun/App/OpenBitFunApp.swift b/src/apps/mobile/ios/OpenBitFun/App/OpenBitFunApp.swift index f7792e7bc7..c06f00e5ec 100644 --- a/src/apps/mobile/ios/OpenBitFun/App/OpenBitFunApp.swift +++ b/src/apps/mobile/ios/OpenBitFun/App/OpenBitFunApp.swift @@ -10,7 +10,11 @@ struct OpenBitFunApp: App { var body: some Scene { WindowGroup { - if let scenario = designPreviewScenario { + if MobileLaunchConfiguration.streamingRegressionPreview { + #if DEBUG + StreamingRegressionView() + #endif + } else if let scenario = designPreviewScenario { MobileDesignGallery(scenario: scenario) .preferredColorScheme(scenario.appearance == "dark" ? .dark : .light) } else { diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift index c569dc19a1..0dfc2e40b1 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift @@ -1,31 +1,62 @@ import Foundation +import OSLog import OpenBitFunMobileCore import SwiftUI import UIKit struct ChatTimelineView: View { @ObservedObject var model: MobileAppModel - @State private var userScrolledUp = false + var onLoadOlderMessages: (() -> Void)? = nil + @StateObject private var scrollController = TimelineScrollController() + @State private var historyAnchor: (id: String, top: CGFloat, firstID: String)? var body: some View { ScrollViewReader { proxy in ScrollView(showsIndicators: false) { - LazyVStack(spacing: MobileDesignGeometry.messageSpacing) { - if model.surface == .remote && model.remoteHasMoreMessages { - Button { model.loadOlderRemoteMessages() } label: { - HStack(spacing: 7) { - if model.busy { ProgressView().controlSize(.small) } - Text(model.localized(model.busy ? "正在加载" : "加载更早消息")) - .font(MobileDesignTypography.labelSmall.font) + VStack(spacing: MobileDesignGeometry.messageSpacing) { + // History is already paged by the session store. Measure the + // loaded page exactly: an estimated lazy history above a growing + // 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.remoteHasMoreMessages { + Button { + #if DEBUG + Logger(subsystem: "com.openbitfun.mobile.ios", category: "timeline-scroll").info("History capture frames=\(scrollController.rowFrames.count)") + #endif + if let row = scrollController.rowFrames.filter({ $0.value.maxY > 0 }) + .min(by: { $0.value.minY < $1.value.minY }) + { + historyAnchor = (row.key, row.value.minY, model.timelineRows.first?.id ?? "") + #if DEBUG + Logger(subsystem: "com.openbitfun.mobile.ios", category: "timeline-scroll").info("History capture top=\(row.value.minY) height=\(row.value.height)") + #endif + } + scrollController.stopFollowing() + if let onLoadOlderMessages { + onLoadOlderMessages() + } else { + model.loadOlderRemoteMessages() + } + } label: { + HStack(spacing: 7) { + if model.busy { ProgressView().controlSize(.small) } + Text(model.localized(model.busy ? "正在加载" : "加载更早消息")) + .font(MobileDesignTypography.labelSmall.font) + } + .foregroundStyle(OpenBitFunTheme.muted) + .frame(maxWidth: .infinity, minHeight: 38) } - .foregroundStyle(OpenBitFunTheme.muted) - .frame(maxWidth: .infinity, minHeight: 38) + .buttonStyle(.plain) + .disabled(model.busy) + .accessibilityIdentifier("timeline.loadOlder") } - .buttonStyle(.plain) - .disabled(model.busy) + ForEach(historyRows) { row in timelineRow(row) } } - ForEach(model.timelineRows) { row in - ConversationRowView(row: row, model: model).id(row.id) + // Keep the user's message and its reply in the same measured + // tail, including acknowledgement and completion transitions. + ForEach(currentTurnItems) { item in + timelineRow(item.row, identity: item.id) } if model.timelineRows.isEmpty && model.isSending { TypingIndicator().frame(maxWidth: .infinity, alignment: .leading) @@ -35,43 +66,58 @@ struct ChatTimelineView: View { .padding(.horizontal, MobileDesignGeometry.contentGutter) .padding(.top, MobileDesignGeometry.timelineTopPadding) .padding(.bottom, 14) + .background(TimelineScrollProbe(controller: scrollController)) } - .simultaneousGesture( - DragGesture(minimumDistance: 8).onChanged { value in - if value.translation.height > 8 { userScrolledUp = true } - } - ) - .scrollDismissesKeyboard(.interactively) - .onChange(of: model.selectedSessionID) { _ in - userScrolledUp = false - Task { @MainActor in - await Task.yield() - proxy.scrollTo("timeline-bottom", anchor: .bottom) + .coordinateSpace(name: "chat-timeline") + .onPreferenceChange(TimelineRowFramesKey.self) { frames in + scrollController.rowFrames = frames + // Apply corrections from this measurement directly. Publishing + // frames into State adds a second, potentially stale layout pass. + guard let anchor = historyAnchor else { return } + if scrollController.isUserScrolling { + historyAnchor = nil + } else if historyRestoreRequest != nil, model.timelineRows.first?.id != anchor.firstID, + let frame = frames[anchor.id] + { + // Preserve the actual visible row, including its partial offset, + // rather than guessing from a lazy stack's total height. + scrollController.preserveAnchor(displacement: frame.minY - anchor.top) } } - .onChange(of: model.isSending) { sending in - guard sending else { return } - userScrolledUp = false - Task { @MainActor in - await Task.yield() - proxy.scrollTo("timeline-bottom", anchor: .bottom) + .task(id: historyRestoreRequest) { + #if DEBUG + Logger(subsystem: "com.openbitfun.mobile.ios", category: "timeline-scroll").info("History task available=\(historyRestoreRequest != nil)") + #endif + guard let request = historyRestoreRequest else { return } + await Task.yield() + guard !Task.isCancelled, !scrollController.isUserScrolling, + historyRestoreRequest == request + else { return } + // Explicitly key restoration by both the captured anchor and the + // new transcript. State used only inside callbacks may otherwise + // arrive after the list's first layout notification. + proxy.scrollTo(scrollTargetID(request.id), anchor: .top) + await Task.yield() + guard !Task.isCancelled, historyRestoreRequest == request else { return } + if let frame = scrollController.rowFrames[request.id] { + scrollController.preserveAnchor(displacement: frame.minY - request.top) } } - .onChange(of: model.timelineRows) { _ in - guard !userScrolledUp else { return } - Task { @MainActor in - await Task.yield() - guard !userScrolledUp else { return } - proxy.scrollTo("timeline-bottom", anchor: .bottom) - } + .scrollDismissesKeyboard(.interactively) + .onAppear { scrollController.open(session: model.selectedSessionID) } + .onChange(of: model.selectedSessionID) { session in + historyAnchor = nil + scrollController.open(session: session) + } + .onChange(of: model.composerSendGeneration) { _ in + historyAnchor = nil + scrollController.followBottom() } .overlay(alignment: .bottomTrailing) { - if userScrolledUp { + if !scrollController.followsBottom { Button { - userScrolledUp = false - withAnimation(.easeOut(duration: 0.18)) { - proxy.scrollTo("timeline-bottom", anchor: .bottom) - } + historyAnchor = nil + scrollController.followBottom() } label: { Image(systemName: "chevron.down") .font(.system(size: 13, weight: .semibold)) @@ -84,11 +130,84 @@ struct ChatTimelineView: View { } .buttonStyle(.plain) .padding(18) + .accessibilityIdentifier("timeline.scrollToBottom") .accessibilityLabel(Text(model.localized("滚动到底部"))) } } + .background(OpenBitFunTheme.page) } - .background(OpenBitFunTheme.page) + } + + private struct HistoryRestoreRequest: Equatable { + let session: String + let firstID: String + let id: String + let top: CGFloat + } + + private var historyRestoreRequest: HistoryRestoreRequest? { + guard let anchor = historyAnchor, let first = model.timelineRows.first?.id, + first != anchor.firstID + else { return nil } + return HistoryRestoreRequest( + session: model.selectedSessionID, firstID: first, + id: anchor.id, top: anchor.top) + } + + private var currentTurnStart: Int { + if let userIndex = model.timelineRows.lastIndex(where: { $0.kind == "USER" }) { + return userIndex + } + return model.timelineRows.last?.live == true + ? max(0, model.timelineRows.count - 1) : model.timelineRows.count + } + + private var currentTurnRows: ArraySlice { + model.timelineRows.dropFirst(currentTurnStart) + } + + private struct CurrentTurnItem: Identifiable { + let id: String + let row: MobileConversationRow + } + + private var currentTurnItems: [CurrentTurnItem] { + currentTurnRows.map { CurrentTurnItem(id: scrollTargetID($0.id), row: $0) } + } + + private func scrollTargetID(_ rowID: String) -> String { + // Acknowledgement replaces the transport ID, not the visible message. + // Retain the current user leaf across that replacement; historical rows + // and assistant blocks continue to use their transcript identities. + if model.timelineRows.last(where: { $0.kind == "USER" })?.id == rowID { + return "current-user:\(model.selectedSessionID)" + } + return rowID + } + + private var historyRows: ArraySlice { + model.timelineRows.prefix(currentTurnStart) + } + + private func timelineRow(_ row: MobileConversationRow, identity: String? = nil) -> some View { + ConversationRowView(row: row, model: model, language: model.appLanguage.rawValue) + .equatable().id(identity ?? row.id) + .environment(\.streamingRowIdentity, row.id) + .background(TimelineRowProbe(controller: scrollController, rowID: row.id)) + .background { + GeometryReader { geometry in + OpenBitFunTheme.transparent.preference( + key: TimelineRowFramesKey.self, + value: [row.id: geometry.frame(in: .named("chat-timeline"))]) + } + } + } +} + +private struct TimelineRowFramesKey: PreferenceKey { + static var defaultValue: [String: CGRect] = [:] + static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) { + value.merge(nextValue(), uniquingKeysWith: { _, latest in latest }) } } @@ -108,6 +227,7 @@ struct ConversationLoadingState: View { .background(OpenBitFunTheme.page) .accessibilityElement(children: .ignore) .accessibilityLabel(Text(MobileLocalization.text("正在加载"))) + .accessibilityIdentifier("conversation.loading") } private func assistantSkeleton(width: CGFloat, height: CGFloat) -> some View { @@ -146,9 +266,15 @@ struct ConversationLoadingState: View { } } -private struct ConversationRowView: View { +private struct ConversationRowView: View, Equatable { + @State private var expandedToolID: String? = nil let row: MobileConversationRow - @ObservedObject var model: MobileAppModel + let model: MobileAppModel + let language: String + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.row == rhs.row && lhs.language == rhs.language && lhs.model === rhs.model + } @ViewBuilder var body: some View { @@ -211,10 +337,10 @@ private struct ConversationRowView: View { MessageBlockList(blocks: row.blocks, model: model) } else { if let thinking = row.thinking, !thinking.isEmpty { - ThinkingBlock(text: thinking, streaming: row.streaming) + ThinkingBlock(text: thinking, streaming: row.streaming && row.text.isEmpty && row.tools.isEmpty) } - if !row.text.isEmpty { MarkdownMessageView(text: row.text, model: model) } - if !row.tools.isEmpty { ToolStatusList(tools: row.tools, model: model) } + if !row.text.isEmpty { StreamingMarkdownMessageView(text: row.text, active: row.streaming, model: model) } + if !row.tools.isEmpty { ToolStatusList(tools: row.tools, model: model, expandedToolID: $expandedToolID) } } if !row.images.isEmpty { TimelineImageGrid(images: row.images) } if let error = row.error, !error.isEmpty { @@ -301,20 +427,44 @@ private struct EmptyConversationRow: View { private struct MessageBlockList: View { let blocks: [MobileTimelineBlock] - @ObservedObject var model: MobileAppModel + let model: MobileAppModel var body: some View { VStack(alignment: .leading, spacing: 10) { - ForEach(blocks) { block in - switch block { - case let .text(_, text, _): - if !text.isEmpty { MarkdownMessageView(text: text, model: model) } - case let .thinking(_, text, streaming): - ThinkingBlock(text: text, streaming: streaming) - case let .tools(_, tools): - ToolStatusList(tools: tools, model: model) - case let .subagent(_, title, running, text, children): - SubagentBlock(title: title, running: running, text: text, children: children, model: model) + ForEach(MobileProcessGroup.project(blocks)) { group in + ProcessGroupView(group: group, model: model) + } + } + } +} + +private struct ProcessGroupView: View { + let group: MobileProcessGroup + let model: MobileAppModel + @State private var expandedToolID: String? = nil + @State private var expanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + if group.toolsOnly { + ToolStatusList(tools: group.tools, model: model, expandedToolID: $expandedToolID) + } else { + if group.hasSummary { + ToolSummaryButton(tools: group.tools, model: model, expanded: $expanded) + } + if !group.hasSummary || expanded { + ForEach(group.blocks) { block in + switch block { + case let .text(id, text, streaming): + if !text.isEmpty { StreamingMarkdownMessageView(text: text, active: streaming, model: model, partID: id) } + case let .thinking(id, text, streaming): + ThinkingBlock(text: text, streaming: streaming, partID: id) + case let .tools(_, tools): + ToolStatusList(tools: tools, model: model, expandedToolID: $expandedToolID) + case let .subagent(id, title, running, text, children, status): + SubagentBlock(id: id, title: title, running: running, text: text, children: children, model: model, status: status) + } + } } } } @@ -322,12 +472,17 @@ private struct MessageBlockList: View { } private struct SubagentBlock: View { + let id: String let title: String let running: Bool let text: String let children: [MobileTimelineBlock] - @ObservedObject var model: MobileAppModel - @State private var expanded = true + let model: MobileAppModel + var status: String = "" + @State private var expanded = false + private var failed: Bool { MobileSubagentPresentation.failed(status) } + private var processBlocks: [MobileTimelineBlock] { MobileSubagentPresentation.blocks(children, running: running) } + private var hasDetails: Bool { !processBlocks.isEmpty || !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -338,31 +493,81 @@ private struct SubagentBlock: View { .font(MobileDesignTypography.labelMedium.font).lineLimit(1) Spacer() if running { ProgressView().controlSize(.mini) } - Image(systemName: expanded ? "chevron.up" : "chevron.down") + Text(model.localized(running ? "运行中" : (failed ? "失败" : "已完成"))) .font(MobileDesignTypography.labelSmall.font) + if hasDetails { + Image(systemName: expanded ? "chevron.up" : "chevron.down") + .font(MobileDesignTypography.labelSmall.font) + } } - .foregroundStyle(OpenBitFunTheme.muted) + .foregroundStyle(failed ? OpenBitFunTheme.statusDanger : OpenBitFunTheme.muted) .frame(minHeight: 32) + .contentShape(Rectangle()) } .buttonStyle(.plain) - if expanded { - if !text.isEmpty { MarkdownMessageView(text: text, model: model) } - if !children.isEmpty { MessageBlockList(blocks: children, model: model) } + .accessibilityIdentifier("subagent.toggle.\(id)") + .disabled(!hasDetails) + if expanded && hasDetails { + // The parent content is often the Task summary, not another output. + // Keep it only as a fallback for older hosts without child items. + let blocks = processBlocks + if blocks.isEmpty && !text.isEmpty { outputPreview(text, key: id) } + ForEach(blocks) { block in + switch block { + case let .text(key, value, _): + outputPreview(value, key: key) + case let .thinking(key, value, streaming): + ThinkingBlock(text: value, streaming: streaming, partID: key) + case let .tools(_, tools): + SubagentToolRow(tools: tools, model: model) + case let .subagent(key, title, live, value, nested, status): + SubagentBlock(id: key, title: title, running: live, + text: value, children: nested, model: model, status: status) + } + } } } .padding(.leading, 12) .overlay(alignment: .leading) { Rectangle().fill(OpenBitFunTheme.line).frame(width: 2) } } + + private func outputPreview(_ raw: String, key: String) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(model.localized("子任务输出")) + .font(MobileDesignTypography.labelSmall.font) + .foregroundStyle(OpenBitFunTheme.muted) + Text(MobileSubagentPresentation.preview(raw)) + .font(MobileDesignTypography.bodySmall.font) + .lineSpacing(MobileDesignTypography.bodySmall.lineSpacing) + .lineLimit(4) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("subagent.output.\(key)") + } + } +} + +private struct SubagentToolRow: View { + let tools: [MobileTimelineTool] + let model: MobileAppModel + @State private var expandedToolID: String? + + var body: some View { + ToolStatusList(tools: tools, model: model, expandedToolID: $expandedToolID) + } } private struct ThinkingBlock: View { let text: String let streaming: Bool + let partID: String + @Environment(\.streamingRowIdentity) private var rowID @State private var expanded: Bool - init(text: String, streaming: Bool) { + init(text: String, streaming: Bool, partID: String = "thinking") { self.text = text self.streaming = streaming + self.partID = partID _expanded = State(initialValue: streaming) } @@ -380,8 +585,10 @@ private struct ThinkingBlock: View { } .foregroundStyle(OpenBitFunTheme.muted) .frame(minHeight: 32) + .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityIdentifier("thinking.toggle.\(partID)") if expanded { Text(text) .font(MobileDesignTypography.bodyLarge.font) @@ -390,6 +597,15 @@ private struct ThinkingBlock: View { .textSelection(.enabled) } } + // Match Harmony: preserve manual toggles during one thinking phase, + // then fold when later output starts and expand for a new phase. + .onChange(of: streaming) { running in expanded = running } + .background { + #if DEBUG + ThinkingLayoutProbe(rowID: rowID, partID: partID, characters: text.count, + streaming: streaming, expanded: expanded) + #endif + } } } @@ -409,9 +625,68 @@ private struct TypingIndicator: View { } } +private struct StreamingRowIdentityKey: EnvironmentKey { + static let defaultValue = "" +} + +private extension EnvironmentValues { + var streamingRowIdentity: String { + get { self[StreamingRowIdentityKey.self] } + set { self[StreamingRowIdentityKey.self] = newValue } + } +} + +private struct StreamingMarkdownMessageView: View { + let text: String + let active: Bool + let model: MobileAppModel + var partID = "body" + @Environment(\.streamingRowIdentity) private var rowID + + var body: some View { + let key = "\(model.remoteTargetEpoch)|\(model.selectedSessionID)|\(rowID)|\(partID)" + StreamingMarkdownRevealView(text: text, active: active, model: model, key: key) + .id(key) + } +} + +private struct StreamingMarkdownRevealView: View { + let text: String + let active: Bool + let model: MobileAppModel + let key: String + @State private var reveal: StreamingTextState + + init(text: String, active: Bool, model: MobileAppModel, key: String) { + self.text = text + self.active = active + self.model = model + self.key = key + var state = StreamingTextState(text: active ? StreamingRevealCache.shared.text(for: key) : text) + state.update(text, active: active) + _reveal = State(initialValue: state) + } + + var body: some View { + MarkdownMessageView(text: reveal.visible, model: model) + .onChange(of: text) { value in reveal.update(value, active: active) } + .onChange(of: active) { value in reveal.update(text, active: value) } + .onChange(of: reveal.visible) { value in StreamingRevealCache.shared.save(value, for: key) } + .onDisappear { StreamingRevealCache.shared.save(reveal.visible, for: key) } + .task(id: active && reveal.ticksRemaining > 0) { + guard active && reveal.ticksRemaining > 0 else { return } + while !Task.isCancelled && reveal.ticksRemaining > 0 { + do { try await Task.sleep(nanoseconds: 40_000_000) } + catch { return } + reveal.advance() + } + } + } +} + struct MarkdownMessageView: View { let text: String - @ObservedObject var model: MobileAppModel + let model: MobileAppModel var body: some View { let projection = MarkdownProjectionCache.shared.projection(for: text) @@ -846,7 +1121,9 @@ private enum ToolDisplayRow: Identifiable { private struct ToolStatusList: View { let tools: [MobileTimelineTool] - @ObservedObject var model: MobileAppModel + let model: MobileAppModel + @Binding var expandedToolID: String? + @State private var summariesExpanded = false var body: some View { VStack(alignment: .leading, spacing: 4) { @@ -864,8 +1141,8 @@ private struct ToolStatusList: View { else if model.isSending || model.busy { Text(model.localized("请等待当前操作完成")) } }.padding(14).frame(maxWidth: .infinity, alignment: .leading) .background(OpenBitFunTheme.soft).clipShape(RoundedRectangle(cornerRadius: 14)) - } else { ToolStatusRow(tool: tool, model: model) } - case let .collapsed(_, tools): CollapsedToolsRow(tools: tools, model: model) + } else { ToolStatusRow(tool: tool, model: model, expandedToolID: $expandedToolID) } + case let .collapsed(_, tools): CollapsedToolsRow(tools: tools, model: model, expanded: $summariesExpanded, expandedToolID: $expandedToolID) } } } @@ -877,7 +1154,7 @@ private struct ToolStatusList: View { func flush() { if pending.count < 2 { result.append(contentsOf: pending.map(ToolDisplayRow.tool)) } else if let first = pending.first { - result.append(.collapsed(id: "collapsed-\(first.id)-\(pending.count)", tools: pending)) + result.append(.collapsed(id: "collapsed-\(first.id)", tools: pending)) } pending.removeAll() } @@ -889,31 +1166,42 @@ private struct ToolStatusList: View { } } +private struct ToolSummaryButton: View { + let tools: [MobileTimelineTool] + let model: MobileAppModel + @Binding var expanded: Bool + + var body: some View { + Button { withAnimation(.easeOut(duration: 0.18)) { expanded.toggle() } } label: { + HStack(spacing: 8) { + Image(systemName: "doc.on.doc").font(.system(size: 12, weight: .medium)) + .frame(width: 20, height: 20).background(OpenBitFunTheme.soft) + .clipShape(RoundedRectangle(cornerRadius: 6)) + Text(model.localizedFormat("已完成 %lld 项操作", Int64(tools.count))) + .font(MobileDesignTypography.bodySmall.font) + Spacer() + Image(systemName: expanded ? "chevron.up" : "chevron.down") + .font(.system(size: 10, weight: .semibold)) + } + .foregroundStyle(OpenBitFunTheme.muted).frame(minHeight: 32) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("tool.summary.\(tools.first?.id ?? "empty")") + } +} + private struct CollapsedToolsRow: View { let tools: [MobileTimelineTool] - @ObservedObject var model: MobileAppModel - @State private var expanded = false + let model: MobileAppModel + @Binding var expanded: Bool + @Binding var expandedToolID: String? var body: some View { VStack(alignment: .leading, spacing: 3) { - Button { withAnimation(.easeOut(duration: 0.18)) { expanded.toggle() } } label: { - HStack(spacing: 8) { - Image(systemName: "doc.on.doc").font(.system(size: 12, weight: .medium)) - .frame(width: 20, height: 20).background(OpenBitFunTheme.soft) - .clipShape(RoundedRectangle(cornerRadius: 6)) - Text(model.localizedFormat("已完成 %lld 项操作", Int64(tools.count))) - .font(MobileDesignTypography.bodySmall.font) - Spacer() - Image(systemName: expanded ? "chevron.up" : "chevron.down") - .font(.system(size: 10, weight: .semibold)) - } - .foregroundStyle(OpenBitFunTheme.muted).frame(minHeight: 32) - } - .buttonStyle(.plain) + ToolSummaryButton(tools: tools, model: model, expanded: $expanded) if expanded { - VStack(alignment: .leading, spacing: 3) { - ForEach(tools) { ToolStatusRow(tool: $0, model: model) } - } + ForEach(tools) { ToolStatusRow(tool: $0, model: model, expandedToolID: $expandedToolID) } } } } @@ -922,7 +1210,8 @@ private struct CollapsedToolsRow: View { private struct ToolStatusRow: View { let tool: MobileTimelineTool @ObservedObject var model: MobileAppModel - @State private var expanded = false + @Binding var expandedToolID: String? + private var expanded: Bool { expandedToolID == tool.id } @State private var answer = "" @State private var selectedOptions: [Int: Set] = [:] @State private var otherAnswers: [Int: String] = [:] @@ -933,7 +1222,7 @@ private struct ToolStatusRow: View { VStack(alignment: .leading, spacing: 8) { Button { if !tool.input.isEmpty || !tool.output.isEmpty || !tool.filePath.isEmpty { - withAnimation(.easeOut(duration: 0.18)) { expanded.toggle() } + withAnimation(.easeOut(duration: 0.18)) { expandedToolID = expanded ? nil : tool.id } } } label: { HStack(spacing: 8) { @@ -947,8 +1236,10 @@ private struct ToolStatusRow: View { else { Text(statusMark).font(MobileDesignTypography.labelSmall.font).foregroundStyle(statusColor) } } .frame(minHeight: 32) + .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityIdentifier("tool.toggle.\(tool.id)") if expanded { if !tool.filePath.isEmpty { diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift index efb24962a5..b0b8dfc00c 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift @@ -95,6 +95,10 @@ struct ComposerBar: View { .background(OpenBitFunTheme.page) .animation(.easeOut(duration: 0.22), value: expanded) .animation(.easeOut(duration: 0.18), value: model.composerImages.count) + .onChange(of: model.composerSendGeneration) { _ in + focused = false + modelSelectorOpen = false + } .onDisappear { speech.stop() } .onChange(of: scenePhase) { if $0 != .active { speech.stop() } } .onChange(of: model.selectedSessionID) { _ in speech.stop() } @@ -231,7 +235,7 @@ struct ComposerBar: View { .accessibilityIdentifier("composer.input") .submitLabel(.send) .onSubmit { - if canSend { model.send() } + if canSend { submitMessage() } } if showsSupplementalVoice, !expanded { supplementalVoiceAction @@ -494,7 +498,7 @@ struct ComposerBar: View { case .stopTurn: model.stopSending() case .send: - model.send() + submitMessage() case .sendBlocked, .voiceBlocked: return case .voice: @@ -502,6 +506,13 @@ struct ComposerBar: View { } } + private func submitMessage() { + guard model.send() else { return } + speech.stop() + focused = false + modelSelectorOpen = false + } + private func startVoiceInput() { let existing = model.draft speech.start( diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/TimelineScrollController.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/TimelineScrollController.swift new file mode 100644 index 0000000000..4fd2a6e9c4 --- /dev/null +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/TimelineScrollController.swift @@ -0,0 +1,317 @@ +import SwiftUI +import UIKit +import OSLog + +/// Observe the native scroll geometry, without replacing SwiftUI's scroll delegate. +/// Content-size observations also cover Markdown reveal, image loading and tool expansion. +@MainActor +final class TimelineScrollController: ObservableObject { + @Published private(set) var followsBottom = true + private weak var scrollView: UIScrollView? + private var observations: [NSKeyValueObservation] = [] + private var updateScheduled = false + private var applyingOffset = false + private var interactionRevision = 0 + private var anchorScheduled = false + private var pendingAnchorDisplacement: CGFloat = 0 + private var sessionID = "" + // Geometry telemetry must not invalidate the SwiftUI tree on every scroll pixel. + private final class WeakRow { + weak var view: UIView? + init(_ view: UIView) { self.view = view } + } + private var rowViews: [String: WeakRow] = [:] + private var preferenceFrames: [String: CGRect] = [:] + var rowFrames: [String: CGRect] { + get { + guard let scroll = scrollView else { return preferenceFrames } + var frames = preferenceFrames + for (id, weakRow) in rowViews { + guard let view = weakRow.view, view.window != nil else { continue } + let rect = view.convert(view.bounds, to: scroll) + frames[id] = rect.offsetBy(dx: -scroll.bounds.minX, dy: -scroll.bounds.minY) + } + return frames + } + set { preferenceFrames = newValue } + } + + func registerRow(_ id: String, view: UIView) { + guard rowViews[id]?.view !== view else { return } + // A retained message view can acquire its acknowledged transcript ID. + // Drop its old alias so history anchoring never selects a stale row. + rowViews = rowViews.filter { $0.value.view != nil && ($0.key == id || $0.value.view !== view) } + rowViews[id] = WeakRow(view) + } + #if DEBUG + private var lastDiagnosticTime: TimeInterval = 0 + private var lastDiagnosticHeight: CGFloat = 0 + private var lastDiagnosticOffset: CGFloat = 0 + + private func traceGeometry(_ event: String, force: Bool = false) { + guard let scroll = scrollView else { return } + let now = ProcessInfo.processInfo.systemUptime + let deltaH = scroll.contentSize.height - lastDiagnosticHeight + let deltaY = scroll.contentOffset.y - lastDiagnosticOffset + guard force || now - lastDiagnosticTime >= 0.2 || abs(deltaH) > 100 || abs(deltaY) > 100 else { return } + lastDiagnosticTime = now + lastDiagnosticHeight = scroll.contentSize.height + lastDiagnosticOffset = scroll.contentOffset.y + log.info("ScrollTrace event=\(event, privacy: .public) content_h=\(scroll.contentSize.height) viewport_h=\(scroll.bounds.height) offset_y=\(scroll.contentOffset.y) delta_h=\(deltaH) delta_y=\(deltaY) bottom_gap=\(self.bottomOffset(scroll) - scroll.contentOffset.y) following=\(self.followsBottom) applying=\(self.applyingOffset) tracking=\(scroll.isTracking) dragging=\(scroll.isDragging) decelerating=\(scroll.isDecelerating)") + } + #endif + private var previousSize: CGSize = .zero + private let log = Logger(subsystem: "com.openbitfun.mobile.ios", category: "timeline-scroll") + private var lastGeometryLogTime: TimeInterval = 0 + + private func logGeometry(_ reason: String, force: Bool = false) { + guard let scroll = scrollView else { return } + let now = Date.timeIntervalSinceReferenceDate + guard force || now - lastGeometryLogTime >= 1 else { return } + lastGeometryLogTime = now + log.info("Scroll reason=\(reason, privacy: .public) content_h=\(scroll.contentSize.height) viewport_h=\(scroll.bounds.height) offset_y=\(scroll.contentOffset.y) bottom_y=\(self.bottomOffset(scroll)) following=\(self.followsBottom) dragging=\(scroll.isDragging) decelerating=\(scroll.isDecelerating) enabled=\(scroll.isScrollEnabled)") + } + + func attach(_ scroll: UIScrollView) { + guard scrollView !== scroll else { return } + observations.removeAll() + scrollView = scroll + previousSize = scroll.bounds.size + observations = [ + scroll.observe(\.contentSize, options: [.new]) { [weak self] _, _ in + MainActor.assumeIsolated { + #if DEBUG + self?.traceGeometry("content-size") + #endif + self?.scheduleFollow() + } + }, + scroll.observe(\.bounds, options: [.new]) { [weak self] scroll, _ in + MainActor.assumeIsolated { + guard let self else { return } + if self.previousSize != scroll.bounds.size { + self.previousSize = scroll.bounds.size + self.scheduleFollow() + } + #if DEBUG + self.traceGeometry("bounds") + #endif + self.observeUserScroll(scroll) + } + } + ] + logGeometry("attach", force: true) + scheduleFollow() + } + + func open(session: String) { + guard sessionID != session else { return } + sessionID = session + followBottom() + } + + func followBottom() { + interactionRevision += 1 + pendingAnchorDisplacement = 0 + followsBottom = true + logGeometry("follow-request", force: true) + scheduleFollow() + } + + var isUserScrolling: Bool { + guard let scroll = scrollView else { return false } + // Tracking also includes a stationary tap on a button inside the list. + // Only an actual drag/deceleration cancels history-position restoration. + return scroll.isDragging || scroll.isDecelerating + } + + func stopFollowing() { interactionRevision += 1; followsBottom = false } + + func preserveAnchor(displacement: CGFloat) { + guard !isUserScrolling, !followsBottom, abs(displacement) > 0.5 else { return } + pendingAnchorDisplacement = displacement + guard !anchorScheduled else { return } + anchorScheduled = true + let revision = interactionRevision + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.anchorScheduled = false + guard revision == self.interactionRevision, !self.isUserScrolling, + !self.followsBottom, let scroll = self.scrollView else { return } + let delta = self.pendingAnchorDisplacement + self.pendingAnchorDisplacement = 0 + self.applyingOffset = true + scroll.setContentOffset(CGPoint(x: scroll.contentOffset.x, + y: scroll.contentOffset.y + delta), animated: false) + self.applyingOffset = false + } + } + + private func observeUserScroll(_ scroll: UIScrollView) { + guard !applyingOffset, scroll.isTracking || scroll.isDragging || scroll.isDecelerating else { return } + logGeometry("user-scroll") + let atBottom = bottomOffset(scroll) - scroll.contentOffset.y <= 24 + let revision = interactionRevision + // KVO can run inside a layout pass; publish presentation state on the next turn. + DispatchQueue.main.async { [weak self] in + guard let self, self.interactionRevision == revision else { return } + self.followsBottom = atBottom + } + } + + private func bottomOffset(_ scroll: UIScrollView) -> CGFloat { + max(-scroll.adjustedContentInset.top, + scroll.contentSize.height - scroll.bounds.height + scroll.adjustedContentInset.bottom) + } + + private func scheduleFollow() { + guard !updateScheduled else { return } + updateScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.updateScheduled = false + guard self.followsBottom, let scroll = self.scrollView, + !scroll.isTracking, !scroll.isDragging, !scroll.isDecelerating else { return } + #if DEBUG + let layoutStart = ProcessInfo.processInfo.systemUptime + self.traceGeometry("follow-before-layout") + #endif + scroll.layoutIfNeeded() + #if DEBUG + let layoutMS = (ProcessInfo.processInfo.systemUptime - layoutStart) * 1_000 + if layoutMS > 16 { self.log.info("ScrollTrace event=slow-layout duration_ms=\(layoutMS)") } + #endif + let y = self.bottomOffset(scroll) + guard abs(y - scroll.contentOffset.y) > 0.5 else { return } + self.logGeometry("follow-apply") + self.applyingOffset = true + scroll.setContentOffset(CGPoint(x: scroll.contentOffset.x, y: y), animated: false) + #if DEBUG + self.traceGeometry("follow-after-offset") + #endif + self.applyingOffset = false + } + } +} + +/// A passive fallback for lazy rows whose unchanged SwiftUI preferences may +/// not be delivered after a transcript replacement. Read geometry at capture time. +struct TimelineRowProbe: UIViewRepresentable { + let controller: TimelineScrollController + let rowID: String + + func makeUIView(context: Context) -> UIView { + let view = UIView() + view.isUserInteractionEnabled = false + controller.registerRow(rowID, view: view) + return view + } + + func updateUIView(_ view: UIView, context: Context) { + controller.registerRow(rowID, view: view) + } +} + +struct TimelineScrollProbe: UIViewRepresentable { + let controller: TimelineScrollController + + func makeUIView(context: Context) -> ProbeView { + let view = ProbeView() + view.isUserInteractionEnabled = false + view.controller = controller + return view + } + + func updateUIView(_ uiView: ProbeView, context: Context) { + uiView.controller = controller + uiView.bindScrollView() + } + + final class ProbeView: UIView { + weak var controller: TimelineScrollController? + override func didMoveToWindow() { super.didMoveToWindow(); bindScrollView() } + override func layoutSubviews() { super.layoutSubviews(); bindScrollView() } + + func bindScrollView() { + var parent = superview + while let candidate = parent { + if let scroll = candidate as? UIScrollView { + controller?.attach(scroll) + return + } + parent = candidate.superview + } + } + } +} + +#if DEBUG +/// Passive telemetry: never publishes geometry into SwiftUI state or requests layout. +struct ThinkingLayoutProbe: UIViewRepresentable { + let rowID: String + let partID: String + let characters: Int + let streaming: Bool + let expanded: Bool + + func makeUIView(context: Context) -> ProbeView { + let view = ProbeView() + view.isUserInteractionEnabled = false + return view + } + + func updateUIView(_ view: ProbeView, context: Context) { + let changed = view.characters != characters || view.streaming != streaming || view.expanded != expanded + view.rowID = rowID + view.partID = partID + view.characters = characters + view.streaming = streaming + view.expanded = expanded + if changed { view.record("state", force: true) } + } + + static func dismantleUIView(_ view: ProbeView, coordinator: ()) { + view.record("dismantle", force: true) + } + + final class ProbeView: UIView { + var rowID = "" + var partID = "" + var characters = 0 + var streaming = false + var expanded = false + private let instance = String(UUID().uuidString.prefix(8)) + private let log = Logger(subsystem: "com.openbitfun.mobile.ios", category: "thinking-layout") + private var lastTime: TimeInterval = 0 + private var lastHeight: CGFloat = -1 + private var exceededViewport = false + + override func didMoveToWindow() { + super.didMoveToWindow() + record(window == nil ? "detach" : "attach", force: true) + } + + override func layoutSubviews() { + super.layoutSubviews() + record("layout") + } + + func record(_ event: String, force: Bool = false) { + var ancestor = superview + while ancestor != nil && !(ancestor is UIScrollView) { ancestor = ancestor?.superview } + let scroll = ancestor as? UIScrollView + let viewport = scroll?.bounds.height ?? 0 + let exceeds = viewport > 0 && bounds.height >= viewport + let crossed = exceeds != exceededViewport + let now = ProcessInfo.processInfo.systemUptime + guard force || crossed || (bounds.height != lastHeight && now - lastTime >= 0.2) else { return } + let delta = bounds.height - lastHeight + lastHeight = bounds.height + lastTime = now + exceededViewport = exceeds + let top = scroll.map { convert(bounds, to: $0).minY - $0.bounds.minY } ?? 0 + log.info("Thinking event=\(event, privacy: .public) instance=\(self.instance, privacy: .public) row=\(self.rowID, privacy: .private(mask: .hash)) part=\(self.partID, privacy: .private(mask: .hash)) chars=\(self.characters) streaming=\(self.streaming) expanded=\(self.expanded) width=\(self.bounds.width) height=\(self.bounds.height) delta_h=\(delta) viewport_h=\(viewport) exceeds_viewport=\(exceeds) crossed_viewport=\(crossed) visible_top=\(top) content_h=\(scroll?.contentSize.height ?? 0) offset_y=\(scroll?.contentOffset.y ?? 0) dragging=\(scroll?.isDragging ?? false)") + } + } +} +#endif diff --git a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/MobileDesignGallery.swift b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/MobileDesignGallery.swift index f121d9cfa2..feff6b7b2d 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/MobileDesignGallery.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/MobileDesignGallery.swift @@ -82,3 +82,190 @@ struct MobileDesignGallery: View { MobileDesignGallery(scenario: MobilePreviewScenarios.streamingDark) .preferredColorScheme(.dark) } + +#if DEBUG +/// Deterministic native rendering fixture. It never opens a remote transport. +struct StreamingRegressionView: View { + @StateObject private var model = MobileAppModel(sessions: [], selectedSessionID: "fixture", messages: [], connectCore: false) + @State private var generation = 0 + @State private var finished = false + @State private var olderCount = 0 + @State private var historyCount = 40 + + var body: some View { + VStack(spacing: 0) { + HStack { + Button("Start stream") { + model.draft = "" + model.composerSendGeneration &+= 1 + model.isSending = true + finished = false + generation += 1 + }.accessibilityIdentifier("fixture.send") + Button("Reset session") { + generation = 0 + model.selectedSessionID = UUID().uuidString + model.timelineRows = history + model.isSending = false + }.accessibilityIdentifier("fixture.reset") + } + Button("Short history") { + generation = 0 + historyCount = 6 + olderCount = 0 + model.timelineRows = history + model.remoteHasMoreMessages = true + }.accessibilityIdentifier("fixture.shortHistory") + if ProcessInfo.processInfo.arguments.contains("--open-loading-regression") { + HStack { + Button("Open delayed session") { + model.remoteExpectedDeviceKey = "account:fixture-device" + model.remoteInitialSessionReady = false + model.remoteInitialWorkspaceReady = false + model.selectDirectorySession(ChatSession(id: "delayed", title: "Delayed session", + updatedLabel: "", deviceKey: "fixture-device")) + }.accessibilityIdentifier("fixture.openDelayed") + Button("Bind target") { + model.apply(remoteTargetBound: "account:fixture-device", epoch: model.remoteTargetEpoch, + accountGeneration: model.accountGeneration) + }.accessibilityIdentifier("fixture.bindTarget") + Button("Finish loading") { + model.timelineRows = [row(id: "loaded", text: "LOADED-SESSION", live: false)] + model.finishRemoteConversationOpenIfReady(timelineSessionID: "delayed") + }.accessibilityIdentifier("fixture.finishLoading") + } + } + Text(finished ? "Stream finished" : "Stream fixture") + .accessibilityIdentifier("fixture.status") + if ProcessInfo.processInfo.arguments.contains("--fixture-shell") { + MobileShellView(model: model) + } else { + ChatTimelineView(model: model, onLoadOlderMessages: { + olderCount += 10 + model.timelineRows = history + model.remoteHasMoreMessages = false + }) + ComposerBar(model: model) + } + } + .onAppear { + model.surface = .remote + model.remoteSessionSelected = true + model.remoteConnected = true + model.connectionPhase = .connected + if ProcessInfo.processInfo.arguments.contains("--status-regression") { + model.remoteExpectedDeviceKey = "account:fixture-device" + model.connectionPhase = .reconnecting + } + model.selectedSessionID = "fixture" + model.timelineRows = ProcessInfo.processInfo.arguments.contains("--card-regression") ? [cardRow] : history + } + .task(id: generation) { + guard generation > 0 else { return } + if ProcessInfo.processInfo.arguments.contains("--card-regression") { + model.timelineRows = [cardRow] + model.isSending = false + return + } + if ProcessInfo.processInfo.arguments.contains("--user-bubble-regression") { + func user(_ id: String) -> MobileConversationRow { + MobileConversationRow(id: id, kind: "USER", text: "SENT-USER-BUBBLE", + thinking: nil, images: [], tools: [], blocks: [], streaming: false, + typing: false, pending: id == "pending-user", showRetry: false, error: nil) + } + model.timelineRows = history + [user("pending-user"), row(id: "live", text: "", live: true)] + do { try await Task.sleep(nanoseconds: 2_000_000_000) } catch { return } + model.timelineRows = history + [user("confirmed-user"), row(id: "live", text: "", live: true)] + do { try await Task.sleep(nanoseconds: 5_000_000_000) } catch { return } + model.timelineRows = history + [user("confirmed-user"), row(id: "live", text: "SHORT-REPLY", live: true)] + do { try await Task.sleep(nanoseconds: 2_000_000_000) } catch { return } + model.timelineRows = history + [user("confirmed-user"), row(id: "final", text: "SHORT-REPLY", live: false)] + model.isSending = false + finished = true + return + } + if ProcessInfo.processInfo.arguments.contains("--thinking-regression") { + var thought = "" + for index in 1...12 { + thought += "Thinking segment \(index): checking chronology and viewport stability.\nCheck input.\nCheck output.\nCheck viewport.\nCheck ordering.\n\n" + model.timelineRows = history + [thinkingRow(thought: thought, answer: "", live: true)] + do { try await Task.sleep(nanoseconds: 350_000_000) } catch { return } + } + do { try await Task.sleep(nanoseconds: 3_000_000_000) } catch { return } + model.timelineRows = history + [thinkingRow(thought: thought, answer: "ANSWER-AFTER-THINKING", live: true)] + do { try await Task.sleep(nanoseconds: 2_000_000_000) } catch { return } + model.timelineRows = history + [thinkingRow(thought: thought, answer: "ANSWER-AFTER-THINKING", live: false)] + model.isSending = false + finished = true + return + } + var body = "" + for index in 1...30 { + do { try await Task.sleep(nanoseconds: 350_000_000) } catch { return } + body += "Paragraph \(index): Unicode 中文 👨‍👩‍👧‍👦. **Streaming text** with a [link](https://example.com).\n\n" + if index % 5 == 0 { + body += "```swift\nlet value = \(index)\nprint(value)\n```\n\n| State | Value |\n| --- | --- |\n| Progress | \(index) |\n\n" + } + model.timelineRows = history + [row(id: "live", text: body, live: true)] + } + guard !Task.isCancelled else { return } + model.timelineRows = history + [row(id: "final", text: body + "STREAM-END", live: false)] + model.isSending = false + finished = true + } + } + + private var cardRow: MobileConversationRow { + func tool(_ id: String, phase: String = "COMPLETED") -> MobileTimelineTool { + MobileTimelineTool(id: id, name: "Read", phase: phase, kind: "FILE", operation: "READ", + target: id, filePath: "", fileLabel: "", input: "INPUT-\(id)", output: "OUTPUT-\(id)", + question: nil, questions: [], actions: [], foldIntoSummary: phase == "COMPLETED") + } + if ProcessInfo.processInfo.arguments.contains("--subagent-detail-regression") { + return MobileConversationRow(id: "subtask-details", kind: "ASSISTANT", text: "", thinking: nil, + images: [], tools: [], blocks: [ + .subagent(id: "details", title: "SUBTASK-DETAILS", running: generation == 0, + text: "PARENT-SUMMARY-MUST-NOT-REPEAT", children: [ + .thinking(id: "child-old", text: "OLD-CHILD-THOUGHT", streaming: true), + .tools(id: "child-tools", tools: [tool("child-one"), tool("child-two")]), + .text(id: "child-output", text: "OUTPUT-PREVIEW " + String(repeating: "long output ", count: 80) + "HIDDEN-OUTPUT-TAIL", streaming: true), + .subagent(id: "nested", title: "NESTED-TASK", running: false, text: "NESTED-OUTPUT", children: []), + .thinking(id: "child-live", text: "LIVE-CHILD-THOUGHT", streaming: true) + ], status: generation == 0 ? "running" : "failed"), + .subagent(id: "empty", title: "EMPTY-TASK", running: false, text: "", children: [], status: "timeout") + ], streaming: false, typing: false, pending: false, showRetry: false, error: nil) + } + return MobileConversationRow(id: "cards", kind: "ASSISTANT", text: "", thinking: nil, + images: [], tools: [], blocks: [ + .subagent(id: "task", title: "TASK-CARD", running: generation == 0, + text: "TASK-BODY", children: []), + .thinking(id: "before", text: "REASON-BEFORE", streaming: false), + .tools(id: "one", tools: [tool("one")]), + .thinking(id: "between", text: "REASON-BETWEEN", streaming: false), + .tools(id: "two", tools: generation > 1 ? [tool("two"), tool("three")] : [tool("two")]), + .tools(id: "running", tools: [tool("running", phase: "RUNNING")]), + .tools(id: "failed", tools: [tool("failed", phase: "FAILED")]), + .text(id: "answer", text: "ANSWER-AFTER-ACTIVITY", streaming: false) + ], streaming: false, typing: false, pending: false, showRetry: false, error: nil) + } + + private func thinkingRow(thought: String, answer: String, live: Bool) -> MobileConversationRow { + var blocks: [MobileTimelineBlock] = [.thinking(id: "thought", text: thought, streaming: answer.isEmpty && live)] + if !answer.isEmpty { blocks.append(.text(id: "answer", text: answer, streaming: live)) } + return MobileConversationRow(id: live ? "thinking-live" : "thinking-final", kind: "ASSISTANT", text: answer, + thinking: thought, images: [], tools: [], blocks: blocks, streaming: live, typing: false, + pending: false, showRetry: false, error: nil, live: live) + } + + private var history: [MobileConversationRow] { + (-olderCount.. MobileConversationRow { + MobileConversationRow(id: id, kind: "ASSISTANT", text: text, thinking: nil, + images: [], tools: [], blocks: [], streaming: live, typing: false, + pending: false, showRetry: false, error: nil, live: live) + } +} +#endif diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Remote/RemoteHomeViews.swift b/src/apps/mobile/ios/OpenBitFun/Features/Remote/RemoteHomeViews.swift index 197069a1d6..11e67fa028 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Remote/RemoteHomeViews.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Remote/RemoteHomeViews.swift @@ -24,6 +24,46 @@ struct RemoteConnectedHomeView: View { if let onBrowse { onBrowse() } else { model.drawerOpen = true } } + private var presentation: RemoteHomePresentation { + RemoteHomePresentation.resolve( + signedIn: model.accountUser != nil, + hasTarget: model.remoteExpectedDeviceKey != nil, + connected: model.remoteConnected && model.connectionPhase == .connected, + reconnecting: model.connectionPhase == .reconnecting, + sessionsLoaded: model.remoteInitialSessionReady + ) + } + + @ViewBuilder + private var homeStatus: some View { + switch presentation { + case .chooseDevice: + Text(model.localized("选择设备和工作区,继续你的对话。")) + .font(.system(size: 13)).foregroundStyle(OpenBitFunTheme.muted) + homeButton("查看设备", action: browse) + case .pairDevice: + Text(model.localized("连接电脑后,继续你的对话。")) + .font(.system(size: 13)).foregroundStyle(OpenBitFunTheme.muted) + homeButton("连接电脑", action: model.connectRemote) + case .connecting, .loadingSessions: + homeButton("查看设备", action: browse) + case .unavailable: + // The shell's connection banner owns the error detail and retry action. + homeButton("查看设备", action: browse) + case .ready: + EmptyView() + } + } + + private func homeButton(_ title: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(model.localized(title)) + .font(.system(size: 15, weight: .medium)) + .frame(maxWidth: .infinity, minHeight: 48) + .background(OpenBitFunTheme.soft, in: RoundedRectangle(cornerRadius: 12)) + }.buttonStyle(.plain).padding(.vertical, 16) + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { @@ -35,17 +75,14 @@ struct RemoteConnectedHomeView: View { .font(.system(size: MobileDesignGeometry.recentHomeTitleSize, weight: .medium)) .multilineTextAlignment(.center).frame(maxWidth: .infinity) .padding(.top, 10).padding(.bottom, 32) - if model.remoteConversationLoading { ProgressView().padding(.bottom, 16) } - if !model.remoteConnected { - Text(model.localized("连接电脑后,继续你的对话。")) - .font(.system(size: 13)).foregroundStyle(OpenBitFunTheme.muted) - Button(model.localized("连接电脑")) { model.connectRemote() }.padding(.vertical, 12) + homeStatus + if !recent.isEmpty || presentation == .ready { + HStack { + Text(model.localized("最近会话")) + Spacer() + Button(model.localized("全部会话"), action: browse) + }.font(.system(size: 12)).foregroundStyle(OpenBitFunTheme.muted) } - HStack { - Text(model.localized("最近会话")) - Spacer() - Button(model.localized("全部会话"), action: browse) - }.font(.system(size: 12)).foregroundStyle(OpenBitFunTheme.muted) ForEach(recent) { session in Button { if session.deviceKey != nil { model.selectDirectorySession(session) } @@ -65,7 +102,7 @@ struct RemoteConnectedHomeView: View { }.buttonStyle(.plain) Rectangle().fill(OpenBitFunTheme.line).frame(height: 0.5) } - if recent.isEmpty && !model.remoteConversationLoading { + if recent.isEmpty && presentation == .ready { Text(model.localized("还没有可继续的对话")) .font(.system(size: 13)).foregroundStyle(OpenBitFunTheme.muted).padding(.vertical, 18) } @@ -78,40 +115,51 @@ struct RemoteConnectedHomeView: View { } } -struct ConnectionStatusBar: View { - let phase: ConnectionPhase - var detail: String? - let onRetry: () -> Void +/// Matches HarmonyOS ChatStatusBar: page background, two hairlines, one status label. +struct RemoteConversationStatusBar: View { + @ObservedObject var model: MobileAppModel + + private var failed: Bool { model.connectionPhase == .disconnected && !(model.coreErrorMessage ?? "").isEmpty } + private var tone: Color { failed ? OpenBitFunTheme.statusDanger : OpenBitFunTheme.muted } + private var title: String { + model.localized(model.isSending ? "OpenBitFun 正在执行..." : + (model.connectionPhase == .reconnecting ? "正在恢复连接" : (failed ? "连接异常" : "已断开"))) + } + private var detail: String { + model.coreErrorMessage ?? model.localized(model.connectionPhase == .reconnecting ? + "正在重新连接桌面端" : "请重新连接") + } + var body: some View { - HStack(spacing: 8) { - Circle().fill(phase == .reconnecting ? OpenBitFunTheme.muted : OpenBitFunTheme.statusDanger).frame(width: 8, height: 8) - Text(MobileLocalization.text(phase == .reconnecting ? "正在恢复连接" : "连接不可用")) - .font(.system(size: 13, weight: .medium)) - Text( - detail ?? MobileLocalization.text( - phase == .reconnecting ? "正在重新连接桌面端" : "请重新连接" - ) - ) - .font(.system(size: 12)) - .foregroundStyle(OpenBitFunTheme.muted) - Spacer() - if phase == .disconnected { - Button(MobileLocalization.text("重试"), action: onRetry) - .font(.system(size: 13, weight: .semibold)) + HStack(spacing: 10) { + if model.isSending { Color.clear.frame(width: 68) } + HStack(spacing: 7) { + Circle().fill(tone).frame(width: 8, height: 8) + Text(detail.isEmpty || detail == title ? title : "\(title) · \(detail)") + .font(MobileDesignTypography.bodySmall.font) + .foregroundStyle(model.isSending ? OpenBitFunTheme.muted : tone) + .lineLimit(1) + .truncationMode(.tail) + } + .frame(maxWidth: .infinity, alignment: model.isSending ? .center : .leading) + if model.isSending { + Button(model.localized("停止"), action: model.stopSending) + .font(MobileDesignTypography.bodySmall.font) + .foregroundStyle(OpenBitFunTheme.ink) + .frame(width: 68, height: 34) + .background(OpenBitFunTheme.soft, in: Capsule()) .buttonStyle(.plain) - .foregroundStyle(OpenBitFunTheme.accent) } } - .foregroundStyle(OpenBitFunTheme.ink) - .padding(.horizontal, 18) + .padding(.horizontal, 24) .frame(height: 48) - .background(OpenBitFunTheme.soft) + .background(OpenBitFunTheme.page) + .overlay(alignment: .top) { OpenBitFunTheme.line.frame(height: 1) } + .overlay(alignment: .bottom) { OpenBitFunTheme.line.frame(height: 1) } + .accessibilityIdentifier("conversation.connectionStatus") } - - } - /// Signed-out home with a fixed contour mark and looping desktop phrases. struct WelcomeHomeView: View { @ObservedObject var model: MobileAppModel diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift index 31e267ecab..1bb7fffc57 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift @@ -336,12 +336,10 @@ struct MobileShellView: View { } if model.surface == .remote, model.remoteExpectedDeviceKey != nil, - model.connectionPhase != .connected { - ConnectionStatusBar( - phase: model.connectionPhase, - detail: model.coreErrorMessage, - onRetry: model.verifyRemoteConnection - ) + model.connectionPhase != .connected, + !model.remoteConversationLoading, + model.remoteSessionSelected || model.connectionPhase == .reconnecting || model.coreErrorMessage != nil { + RemoteConversationStatusBar(model: model) } if showsWelcomeHome { WelcomeHomeView(model: model) diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteFilePreviewView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteFilePreviewView.swift index efb1df2625..f8fad19df6 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteFilePreviewView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteFilePreviewView.swift @@ -62,8 +62,18 @@ struct MobileDownloadDocument: FileDocument { struct RemoteFilePreviewSheet: View { @ObservedObject var model: MobileAppModel - let preview: MobileFilePreview + private let initialPreview: MobileFilePreview var embedded = false + + init(model: MobileAppModel, preview: MobileFilePreview, embedded: Bool = false) { + self.model = model + self.initialPreview = preview + self.embedded = embedded + } + + // sheet(item:) can retain the initial Loading value for the same file ID. + // Read the observed projection so text, image and failure updates render. + private var preview: MobileFilePreview { model.filePreview ?? initialPreview } @Environment(\.dismiss) private var dismiss @State private var visibleLine: Int = 0 @State private var codeLines: [AttributedString] = [] diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift index e667674d06..537df2d3b6 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift @@ -148,8 +148,8 @@ struct SidebarView: View { let anchor = anchors[path] { let frame = proxy[anchor] let menuHeight = HarnessProfilePolicy.shared.supported(capabilities: model.remoteHostCapabilities) - ? 46 * 3 + MobileDesignGeometry.compactPopoverActionHeight + 16 - : MobileDesignGeometry.compactPopoverActionHeight * 2 + 16 + ? 46 * 3 + 16 + : MobileDesignGeometry.compactPopoverActionHeight + 16 ZStack(alignment: .topLeading) { OpenBitFunTheme.transparent .contentShape(Rectangle()) @@ -458,7 +458,8 @@ struct SidebarView: View { directoryLoadStatus: workspace.directoryStatus, onRetryDirectoryLoad: { model.retryDirectoryWorkspace(device: device, workspace: scopedWorkspace) - } + }, + createMenu: directoryCreateMenu(device: device, workspace: scopedWorkspace) ) } if device.workspaces.count > visibleWorkspaceCount { @@ -568,7 +569,11 @@ struct SidebarView: View { } }, onToggleCreate: { - workspaceCreatePath = workspaceCreatePath == workspace.path ? nil : workspace.path + if HarnessProfilePolicy.shared.supported(capabilities: model.remoteHostCapabilities) { + workspaceCreatePath = workspaceCreatePath == workspace.path ? nil : workspace.path + } else { + model.createRemoteSession(in: workspace, agentType: "code") + } }, onOpenWorkspace: { model.selectRemoteWorkspace(workspace) }, onOpenSession: { model.surface = .remote; model.select($0) }, @@ -769,6 +774,26 @@ struct SidebarView: View { .buttonStyle(.plain) } + private func directoryCreateMenu(device: MobileDeviceDirectoryEntry, workspace: MobileWorkspaceGroup) -> AnyView? { + // Capabilities belong to the connected target, never another directory device. + guard model.remoteExpectedDeviceKey == "account:\(device.id)", + HarnessProfilePolicy.shared.supported(capabilities: model.remoteHostCapabilities) else { return nil } + return AnyView(Menu { + ForEach([HarnessProfile.minimal, .standard, .ultimate], id: \.name) { profile in + Button { + model.openDirectoryRemoteDraft(device: device, workspace: workspace, agentType: profile.agentType) + } label: { + HarnessProfileLabel(model: model, profile: profile) + } + } + } label: { + Image(systemName: "plus") + .font(.system(size: 17, weight: .regular)) + .foregroundStyle(OpenBitFunTheme.ink) + .frame(width: 30, height: 40) + }) + } + private func workspaceCreateMenu(_ workspace: MobileWorkspaceGroup) -> some View { VStack(spacing: 0) { if HarnessProfilePolicy.shared.supported(capabilities: model.remoteHostCapabilities) { @@ -789,10 +814,6 @@ struct SidebarView: View { model.createRemoteSession(in: workspace, agentType: "code") } } - workspaceCreateMenuRow("Cowork") { - workspaceCreatePath = nil - model.createRemoteSession(in: workspace, agentType: "Cowork") - } } .openBitFunCompactPopoverSurface() } @@ -962,6 +983,7 @@ private struct SidebarWorkspaceRow: View { var selectedWorkspacePath: String? = nil var directoryLoadStatus = "READY" var onRetryDirectoryLoad: (() -> Void)? = nil + var createMenu: AnyView? = nil @State private var visibleSessionCount = 3 private func isSelected(_ session: ChatSession) -> Bool { @@ -1008,11 +1030,16 @@ private struct SidebarWorkspaceRow: View { .accessibilityIdentifier("sidebar.workspace.\(workspace.deviceKey ?? "unknown").\(workspace.path)") .accessibilityValue(Text(workspace.path)) Spacer(minLength: 0) - Button(action: onToggleCreate) { - Image(systemName: "plus") - .font(.system(size: 17, weight: .regular)) - .foregroundStyle(OpenBitFunTheme.ink) - .frame(width: 30, height: 40) + Group { + if let createMenu { createMenu } + else { + Button(action: onToggleCreate) { + Image(systemName: "plus") + .font(.system(size: 17, weight: .regular)) + .foregroundStyle(OpenBitFunTheme.ink) + .frame(width: 30, height: 40) + } + } } .buttonStyle(.plain) .accessibilityLabel(MobileLocalization.text("新建远程会话")) diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+FilePreview.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+FilePreview.swift index 2a6c315bc4..46fe527d8c 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+FilePreview.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+FilePreview.swift @@ -4,6 +4,7 @@ import OpenBitFunMobileCore private struct MobilePreviewRequestExpectation { let requestID: String let deviceKey: String? + let previewDeviceKey: String? let adapterEpoch: UInt64 let sessionID: String let path: String @@ -67,26 +68,29 @@ extension MobileAppModel { showToast(localized("仅远程工作区文件支持预览")) return } + guard let adapter = coreAdapter, adapter.canOpenRemoteFile else { + showToast(localized("连接不可用,请先重新连接")) + return + } filePreviewLoading = true let key = ObjectIdentifier(self) let requestID = UUID().uuidString - let path = normalizedRemotePath(reference) - let deviceKey = coreAdapter?.currentRemoteTargetKey - let adapterEpoch = coreAdapter?.currentRemoteTargetEpoch ?? 0 - _ = coreAdapter?.openRemoteFile( - reference: reference, - label: label, - sessionID: selectedSessionID, - requestID: requestID - ) + // Register before dispatch: a store may publish Loading immediately. filePreviewRequestByModel[key] = MobilePreviewRequestExpectation( requestID: requestID, - deviceKey: deviceKey, - adapterEpoch: adapterEpoch, + deviceKey: adapter.currentRemoteTargetKey, + previewDeviceKey: adapter.currentFilePreviewDeviceKey, + adapterEpoch: adapter.currentRemoteTargetEpoch, sessionID: selectedSessionID, - path: path, + path: normalizedRemotePath(reference), controlTargetEpoch: nil ) + adapter.openRemoteFile( + reference: reference, + label: label, + sessionID: selectedSessionID, + requestID: requestID + ) } func downloadRemoteFile(reference: String, label: String) { @@ -227,16 +231,17 @@ extension MobileAppModel { let key = ObjectIdentifier(self) if !(state is RemoteFilePreviewUiStateNone) { guard var expected = filePreviewRequestByModel[key], - RemoteAuthorityGate.fileTransferCallbackMatchesAuthority( + let identity = stateRequestIdentity(state), + RemoteAuthorityGate.filePreviewCallbackMatchesAuthority( requestTargetKey: expected.deviceKey, requestEpoch: expected.adapterEpoch, adapterTargetKey: coreAdapter?.currentRemoteTargetKey, - adapterEpoch: coreAdapter?.currentRemoteTargetEpoch ?? 0 + adapterEpoch: coreAdapter?.currentRemoteTargetEpoch ?? 0, + expectedStoreDeviceKey: expected.previewDeviceKey, + callbackDeviceKey: identity.deviceKey ), - let identity = stateRequestIdentity(state), let target = stateTarget(state), identity.requestId == expected.requestID, - identity.deviceKey == expected.deviceKey, identity.sessionId == expected.sessionID, normalizedRemotePath(identity.path) == expected.path, target.sessionId == expected.sessionID, diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift index 4d970ff60c..12644cb101 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift @@ -4,7 +4,8 @@ import ImageIO import OpenBitFunMobileCore extension MobileAppModel { - func send() { sendRemote() } + @discardableResult + func send() -> Bool { sendRemote() } func select(_ session: ChatSession) { pendingDirectoryRemoteDraft = nil @@ -53,8 +54,13 @@ extension MobileAppModel { } func selectModel(_ modelID: String) { - guard selectedSession != nil else { return } - coreAdapter?.selectRemoteModel(sessionID: selectedSessionID, modelID: modelID) + guard surface == .remote, remoteSessionSelected, + let sessionID = RemoteAuthorityGate.sendSessionID( + selectedSessionID: selectedSessionID, openedSessionID: remoteOpenedSessionID, + connected: remoteConnected && connectionPhase == .connected, + busy: busy, sending: isSending + ) else { return } + coreAdapter?.selectRemoteModel(sessionID: sessionID, modelID: modelID) } static func simpleTimelineRow(_ message: ChatMessage) -> MobileConversationRow { @@ -98,7 +104,8 @@ extension MobileAppModel { typing: row.typing, pending: row.pending, showRetry: row.showRetry, - error: row.error + error: row.error, + live: row.live ) } @@ -148,7 +155,8 @@ extension MobileAppModel { title: subagent.title, running: subagent.running, text: subagent.text, - children: subagent.children.map(mapBlock) + children: subagent.children.map(mapBlock), + status: subagent.status ) } return .text(id: block.id, text: "", streaming: false) diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift index e707f3c4f2..cd55f60a10 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift @@ -54,6 +54,7 @@ extension MobileAppModel { private func clearTargetScopedRemoteProjection(boundTargetKey targetKey: String, epoch: UInt64) { resetRemoteConversationOpen() + pendingComposerSend = nil invalidateTargetScopedFileTransfers() remoteOpenedSessionID = nil remoteInitialSessionReady = false @@ -87,6 +88,7 @@ extension MobileAppModel { pending.epoch != epoch || directoryTargetKey(forRawDeviceKey: pending.deviceKey) != targetKey { pendingDirectorySession = nil } + routePendingDirectorySession() if let pending = pendingDirectoryWorkspace, pending.epoch != epoch || directoryTargetKey(forRawDeviceKey: pending.deviceKey) != targetKey { pendingDirectoryWorkspace = nil @@ -201,7 +203,8 @@ extension MobileAppModel { func openDirectoryRemoteDraft( device: MobileDeviceDirectoryEntry, - workspace: MobileWorkspaceGroup + workspace: MobileWorkspaceGroup, + agentType: String = "code" ) { guard !remoteCreateSubmitting, remoteCreateRequestID == nil else { showToast(localized("远程会话当前不可创建,请重试")) @@ -228,6 +231,7 @@ extension MobileAppModel { rawDeviceKey: device.id, workspacePath: workspace.path, normalizedWorkspacePath: normalizedSessionWorkspacePath(workspace.path), + agentType: agentType, epoch: epoch, selectionRequested: false ) @@ -286,11 +290,12 @@ extension MobileAppModel { remoteTargetEpoch &+ (targetIsCurrent ? 0 : 1) ) if targetIsCurrent { - guard remoteConnected else { + guard remoteConnected || accountBusy || connectionPhase == .reconnecting else { pendingDirectorySession = nil showToast(localized("远程会话连接已失效,请重新选择设备后重试")) return } + routePendingDirectorySession() openPendingDirectorySessionIfReady() return } @@ -306,6 +311,22 @@ extension MobileAppModel { return } selectRemoteDevice(device) + routePendingDirectorySession() + openPendingDirectorySessionIfReady() + } + + // Navigation is immediate; authority readiness only gates the remote request. + // Keep the same deferred skeleton gate as HarmonyOS, starting at the tap. + private func routePendingDirectorySession() { + guard let pending = pendingDirectorySession, + pending.epoch == remoteTargetEpoch, + remoteExpectedDeviceKey == directoryTargetKey(forRawDeviceKey: pending.deviceKey) else { return } + surface = .remote + drawerOpen = false + remoteSessionSelected = true + if remoteConversationOpeningSessionID != pending.sessionID { + beginRemoteConversationOpen(sessionID: pending.sessionID) + } } private func openPendingDirectorySessionIfReady() { @@ -320,11 +341,8 @@ extension MobileAppModel { remoteInitialSessionReady, remoteInitialWorkspaceReady, !workspaceLoadFailed else { return } + routePendingDirectorySession() pendingDirectorySession = nil - surface = .remote - drawerOpen = false - remoteSessionSelected = true - beginRemoteConversationOpen(sessionID: pending.sessionID) selectedSessionID = pending.sessionID coreAdapter?.openRemoteSession(sessionID: pending.sessionID) } @@ -397,10 +415,19 @@ extension MobileAppModel { } let selectedPath = workspaceCatalog.first(where: { $0.selected })?.path ?? "" if selectedPath == pending.workspacePath { + guard remoteCreateInteraction.canSubmit else { + mobilePerformanceLog.error("Directory create blocked connected=\(self.remoteConnected) switching=\(self.accountBusy) workspaceReady=\(self.remoteCreateWorkspacePhase == .ready) selecting=\(self.workspaceSelectionBusy) submitting=\(self.remoteCreateSubmitting) activeTurn=\(self.activeTurnID != nil) sending=\(self.isSending)") + pendingDirectoryRemoteDraft = nil + showToast(localized("远程会话当前不可创建,请重试")) + return + } pendingDirectoryRemoteDraft = nil surface = .remote drawerOpen = false - remoteCreateOpen = true + createRemoteSession( + agentType: pending.agentType, title: "", instruction: "", + workspacePath: pending.workspacePath + ) return } guard !pending.selectionRequested else { return } @@ -635,6 +662,9 @@ extension MobileAppModel { default: break } + if !remoteCreateOpen, !remoteCreateSubmitting, let error = remoteCreateError { + showToast(error) + } } func deleteRemoteSession(_ session: ChatSession) { @@ -715,15 +745,42 @@ extension MobileAppModel { ) } - func sendRemote() { + @discardableResult + func sendRemote() -> Bool { let value = draft.trimmingCharacters(in: .whitespacesAndNewlines) guard !value.isEmpty || !composerImages.isEmpty, let sessionID = remoteSendSessionID, - let coreAdapter else { return } + 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 + ) + draft = "" + composerImages = [] + composerSendGeneration &+= 1 isSending = true busy = true coreAdapter.sendRemote(sessionID: sessionID, content: value, images: images) + return true + } + + private func settleComposerSend(ack: SentChatMessage? = nil) { + guard let pending = pendingComposerSend else { return } + let succeeded = ack.map { + $0.sessionId == pending.sessionID && $0.id != pending.previousAckID && + $0.content == pending.text.trimmingCharacters(in: .whitespacesAndNewlines) + } ?? false + pendingComposerSend = nil + if ComposerSendSettlementPolicy.shouldRestore( + sentSession: pending.sessionID, currentSession: selectedSessionID, + acknowledged: succeeded, draftIsEmpty: draft.isEmpty, + attachmentsAreEmpty: composerImages.isEmpty + ) { + draft = pending.text + composerImages = pending.images + } } func buildRemotePlan(path: String, name: String) { @@ -793,6 +850,7 @@ extension MobileAppModel { remoteOpenedSessionID = nil remoteInitialSessionReady = false if let failed = state as? RemoteSessionUiStateFailed { + settleComposerSend() resetRemoteConversationOpen() let detail = failed.remoteMessage ?? failed.reason.name remoteConnected = false @@ -891,14 +949,11 @@ extension MobileAppModel { ) } setPublishedIfChanged(\.busy, to: ready.busy) + if !ready.busy { settleComposerSend(ack: ready.lastSentMessage) } if let sent = ready.lastSentMessage, sent.sessionId == selectedSessionID, sent.id != lastAppliedRemoteSendID { lastAppliedRemoteSendID = sent.id - if draft.trimmingCharacters(in: .whitespacesAndNewlines) == sent.content { - draft = "" - } - composerImages.removeAll { sent.imageIds.contains($0.id) } } setPublishedIfChanged(\.remoteQuery, to: ready.query) setPublishedIfChanged(\.remoteAgentFilter, to: ready.agentFilter.name) @@ -906,8 +961,11 @@ extension MobileAppModel { setPublishedIfChanged(\.remoteHasMoreMessages, to: ready.hasMoreMessages) setPublishedIfChanged(\.remotePermissionMode, to: ready.permissionMode?.name ?? remotePermissionMode) setPublishedIfChanged(\.remotePermissionFailure, to: ready.permissionModeFailure?.name) - activeTurnID = ready.timeline?.activeTurn?.turnId - setPublishedIfChanged(\.isSending, to: ready.timeline?.activeTurn != nil) + let acceptsTimeline = remoteConversationOpeningSessionID.map { + ready.timeline?.sessionId == $0 + } ?? true + activeTurnID = acceptsTimeline ? ready.timeline?.activeTurn?.turnId : nil + setPublishedIfChanged(\.isSending, to: acceptsTimeline && ready.timeline?.activeTurn != nil) let projectedModelOptions = ready.createModelOptions(fallbackLabel: localized("模型")).map { option in ComposerModelOption( id: option.id, @@ -918,9 +976,22 @@ extension MobileAppModel { ) } setPublishedIfChanged(\.modelOptions, to: projectedModelOptions) - if let timeline = ready.timeline { - let projectedRows = timeline.conversationRows().map(Self.mapConversationRow) + if acceptsTimeline, let timeline = ready.timeline { + let projectedRows = MobileConversationRow.reconcile( + timeline.conversationRows().map(Self.mapConversationRow), with: timelineRows) 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 + mobilePerformanceLog.info("Timeline projection rows=\(projectedRows.count) user_rows=\(users.count) previous_user_rows=\(previousUsers.count) removed_user_ids=\(removedUsers) pending_users=\(users.filter(\.pending).count) live_rows=\(projectedRows.filter(\.live).count) blocks=\(projectedRows.reduce(0) { $0 + $1.blocks.count }) busy=\(ready.busy)") + #if DEBUG + if users.map(\.id) != previousUsers.map(\.id) { + let identities = timeline.persistedMessages.filter { $0.role == "user" }.map { + "id=\($0.id),turn=\($0.turnId ?? "none"),time=\($0.timestamp ?? "none"),chars=\($0.text.count)" + }.joined(separator: ";") + mobilePerformanceLog.info("Timeline user identities session=\(timeline.sessionId, privacy: .public) persisted=\(identities, privacy: .public) optimistic=\(timeline.optimisticMessages.count)") + } + #endif timelineRows = projectedRows messages = projectedRows.compactMap { row in guard row.kind != "EMPTY" else { return nil } @@ -998,6 +1069,13 @@ extension MobileAppModel { remoteInitialWorkspaceReady = false } if state is RemoteWorkspaceUiStateFailed || readyState?.loadFailure == true { + if pendingDirectorySession != nil { + pendingDirectorySession = nil + resetRemoteConversationOpen() + remoteSessionSelected = false + busy = false + showToast(localized("工作区加载失败,点按重试")) + } pendingRemoteSessionRefreshWorkspacePath = nil if pendingRemoteWorkspaceCreate != nil || pendingRemoteAssistantCreate || pendingDirectoryRemoteDraft != nil { diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift index 805dfafa41..194af2ef80 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift @@ -32,9 +32,18 @@ final class MobileAppModel: ObservableObject { @Published var remoteCreateDeviceError: String? @Published var selectedSessionID: String @Published var messages: [ChatMessage] - @Published var timelineRows: [MobileConversationRow] = [] + @Published private var renderedTimelineRows: [MobileConversationRow] = [] + var timelineRows: [MobileConversationRow] { + get { renderedTimelineRows } + set { + let next = MobileConversationRow.reconcile(newValue, with: renderedTimelineRows) + if next != renderedTimelineRows { renderedTimelineRows = next } + } + } @Published var draft = "" var lastAppliedRemoteSendID: String? + var pendingComposerSend: PendingComposerSend? + @Published var composerSendGeneration: UInt64 = 0 @Published var drawerOpen = false @Published var settingsOpen = false @Published var remoteControlSettingsOpen = false @@ -92,13 +101,13 @@ final class MobileAppModel: ObservableObject { var pairingGeneration: UInt64 = 0 var accountGeneration: UInt64 = 0 var remoteTargetEpoch: UInt64 = 0 - var remoteExpectedDeviceKey: String? + @Published var remoteExpectedDeviceKey: String? var remoteBoundTargetKey: String? var remoteBoundTargetEpoch: UInt64? var pairingRetainedAccountAuthority: RetainedAccountAuthority? var accountDirectoryGeneration: UInt64 = 0 var pendingDirectorySession: (deviceKey: String, sessionID: String, epoch: UInt64)? - var remoteInitialSessionReady = false + @Published var remoteInitialSessionReady = false var remoteInitialWorkspaceReady = false var remoteCreateRequestID: String? var remoteCreateRequestEpoch: UInt64 = 0 @@ -399,10 +408,11 @@ final class MobileAppModel: ObservableObject { showToast(localized("无法读取所选图片")) return } - guard remoteSessionSelected, connectionPhase != .disconnected, let coreAdapter else { return } + guard let sessionID = remoteSendSessionID, let coreAdapter else { return } + composerSendGeneration &+= 1 isSending = true busy = true - coreAdapter.sendRemote(sessionID: selectedSessionID, content: normalized, images: attachments) + coreAdapter.sendRemote(sessionID: sessionID, content: normalized, images: attachments) } func renameSelectedSession(_ title: String) { diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift index 2303dfca83..94959eb27b 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift @@ -163,6 +163,10 @@ final class MobileCoreAdapter { let state = SkieSwiftStateFlow(account.state).value guard let ready = state as? AccountUiStateReady, ready.selectedDeviceId == id else { return } + // Selecting a persisted target can leave StateFlow unchanged. Publish + // the confirmed snapshot before binding stores so the UI exits switching + // even when no asynchronous account event is emitted. + onAccountState?(ready, accountGeneration) startAccountRemoteSessionIfNeeded(ready: ready, generation: accountGeneration) } @@ -430,6 +434,11 @@ final class MobileCoreAdapter { return .invalidated(newEpoch: remoteTargetEpoch) } + // Workspace-store identities use the raw device ID, not the adapter's + // namespaced account/pairing target key. + var currentFilePreviewDeviceKey: String? { remoteWorkspace?.deviceKey } + var canOpenRemoteFile: Bool { remoteWorkspace != nil } + @discardableResult func openRemoteFile(reference: String, label: String, sessionID: String, requestID: String) -> String? { remoteWorkspace?.dispatch( diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/RemoteAuthorityGate.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/RemoteAuthorityGate.swift index dd168e0032..08f62915ea 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/RemoteAuthorityGate.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/RemoteAuthorityGate.swift @@ -148,6 +148,17 @@ enum RemoteAuthorityGate { return requestTargetKey == adapterTargetKey && requestEpoch == adapterEpoch } + static func filePreviewCallbackMatchesAuthority( + requestTargetKey: String?, requestEpoch: UInt64, + adapterTargetKey: String?, adapterEpoch: UInt64, + expectedStoreDeviceKey: String?, callbackDeviceKey: String? + ) -> Bool { + fileTransferCallbackMatchesAuthority( + requestTargetKey: requestTargetKey, requestEpoch: requestEpoch, + adapterTargetKey: adapterTargetKey, adapterEpoch: adapterEpoch + ) && expectedStoreDeviceKey == callbackDeviceKey + } + static func exactInvalidationMatchesAuthority( expectedTargetKey: String, expectedEpoch: UInt64, @@ -272,3 +283,12 @@ enum RemoteAuthorityGate { ) } } + +enum ComposerSendSettlementPolicy { + static func shouldRestore( + sentSession: String, currentSession: String, + acknowledged: Bool, draftIsEmpty: Bool, attachmentsAreEmpty: Bool + ) -> Bool { + !acknowledged && sentSession == currentSession && draftIsEmpty && attachmentsAreEmpty + } +} diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/RemoteHomePresentation.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/RemoteHomePresentation.swift new file mode 100644 index 0000000000..a0805e0a96 --- /dev/null +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/RemoteHomePresentation.swift @@ -0,0 +1,12 @@ +// Presentation of the current remote target, independent of conversation navigation. +enum RemoteHomePresentation: Equatable { + case chooseDevice, pairDevice, connecting, unavailable, loadingSessions, ready + + static func resolve(signedIn: Bool, hasTarget: Bool, connected: Bool, + reconnecting: Bool, sessionsLoaded: Bool) -> Self { + guard hasTarget else { return signedIn ? .chooseDevice : .pairDevice } + if reconnecting { return .connecting } + guard connected else { return .unavailable } + return sessionsLoaded ? .ready : .loadingSessions + } +} diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/StreamingTextState.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/StreamingTextState.swift new file mode 100644 index 0000000000..b71f3f2aae --- /dev/null +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/StreamingTextState.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Native presentation state. The authoritative text always stays outside the reveal buffer. +struct StreamingTextState: Equatable { + private(set) var visible: String + private(set) var target: String + private(set) var ticksRemaining = 0 + + init(text: String = "") { + visible = text + target = text + } + + 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 { + visible = text + target = text + ticksRemaining = 0 + return + } + target = text + if target == visible { ticksRemaining = 0 } + else if ticksRemaining == 0 { ticksRemaining = 35 } + } + + mutating func advance() { + guard visible != target, ticksRemaining > 0 else { return } + let remaining = target.count - visible.count + let step = max(1, Int(ceil(Double(remaining) / Double(min(8, ticksRemaining))))) + // Character boundaries prevent splitting emoji, combining marks or surrogate pairs. + visible = String(target.prefix(min(target.count, visible.count + step))) + ticksRemaining -= 1 + if visible == target { ticksRemaining = 0 } + } +} + +/// Retains native reveal progress across row reuse and collapsed subagent cards. +/// Entries are scoped by controller epoch, session, row and block at the call site. +@MainActor +final class StreamingRevealCache { + static let shared = StreamingRevealCache() + private let entries = NSCache() + + init() { + entries.countLimit = 128 + entries.totalCostLimit = 4 * 1_024 * 1_024 + } + + func text(for key: String) -> String { + entries.object(forKey: key as NSString) as String? ?? "" + } + + func save(_ text: String, for key: String) { + entries.setObject(text as NSString, forKey: key as NSString, cost: text.utf8.count) + } +} diff --git a/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift b/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift index e1b97a6b0a..ed7281ee20 100644 --- a/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift +++ b/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift @@ -69,19 +69,112 @@ indirect enum MobileTimelineBlock: Identifiable, Equatable { title: String, running: Bool, text: String, - children: [MobileTimelineBlock] + children: [MobileTimelineBlock], + status: String = "" ) var id: String { switch self { case let .text(id, _, _), let .thinking(id, _, _), let .tools(id, _), - let .subagent(id, _, _, _, _): + let .subagent(id, _, _, _, _, _): return id } } } -struct MobileConversationRow: Identifiable, Equatable { +// Subtask details have their own compact presentation, not a nested main transcript. +// Match Harmony SubagentTaskCard: only the last visible child can be thinking live. +struct MobileSubagentPresentation { + static func failed(_ status: String) -> Bool { + ["failed", "error", "timeout", "cancelled", "canceled", "rejected"].contains(status.lowercased()) + } + + static func blocks(_ children: [MobileTimelineBlock], running: Bool) -> [MobileTimelineBlock] { + let visible = children.flatMap { block -> [MobileTimelineBlock] in + switch block { + case let .text(_, text, _), let .thinking(_, text, _): + return text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? [] : [block] + case let .tools(_, tools): + return tools.map { .tools(id: "subtask-tool-\($0.id)", tools: [$0]) } + default: return [block] + } + } + return visible.enumerated().map { index, block in + if case let .thinking(id, text, _) = block { + return .thinking(id: id, text: text, streaming: running && index == visible.count - 1) + } + return block + } + } + + static func preview(_ raw: String) -> String { + let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return text.count <= 320 ? text : String(text.prefix(320)).trimmingCharacters(in: .whitespacesAndNewlines) + "…" + } +} + +// Native rendering groups consume the shared core's foldability decision. +// Thinking stays at its transcript position inside a completed activity summary. +struct MobileProcessGroup: Identifiable { + let id: String + let blocks: [MobileTimelineBlock] + var tools: [MobileTimelineTool] { + blocks.flatMap { if case let .tools(_, tools) = $0 { return tools }; return [] } + } + var toolsOnly: Bool { !blocks.isEmpty && blocks.allSatisfy { if case .tools = $0 { return true }; return false } } + var hasSummary: Bool { tools.count >= 2 && tools.allSatisfy(\.foldIntoSummary) } + + static func project(_ blocks: [MobileTimelineBlock]) -> [MobileProcessGroup] { + var result: [MobileProcessGroup] = [] + var pending: [MobileTimelineBlock] = [] + var toolRun: [MobileTimelineTool] = [] + func flushTools() { + if let first = toolRun.first { + result.append(Self(id: "tool-\(first.id)", blocks: [.tools(id: "tool-\(first.id)", tools: toolRun)])) + } + toolRun.removeAll() + } + func flush() { + guard let first = pending.first else { return } + if pending.contains(where: { if case .thinking = $0 { return true }; return false }) { + flushTools() + result.append(Self(id: first.id, blocks: pending)) + } else { + toolRun.append(contentsOf: pending.flatMap { if case let .tools(_, tools) = $0 { return tools }; return [] }) + } + pending.removeAll() + } + for block in blocks { + switch block { + case let .thinking(id, text, streaming): + // Adjacent reasoning fragments share one header, but never merge across a tool. + if case let .thinking(previousID, previousText, previousStreaming)? = pending.last { + pending[pending.count - 1] = .thinking(id: previousID, + text: [previousText, text].filter { !$0.isEmpty }.joined(separator: "\n\n"), + streaming: previousStreaming || streaming) + } else { pending.append(.thinking(id: id, text: text, streaming: streaming)) } + case let .tools(_, tools): + for tool in tools { + let leaf = MobileTimelineBlock.tools(id: "tool-\(tool.id)", tools: [tool]) + if tool.foldIntoSummary { pending.append(leaf) } + else { flush(); toolRun.append(tool) } + } + default: + flush() + flushTools() + result.append(Self(id: block.id, blocks: [block])) + } + } + flush() + flushTools() + return result + } +} + +/// An immutable render snapshot. SwiftUI compares snapshot identity in O(1), +/// never recursively compares the transcript on focus/sheet/layout transactions. +/// Content reconciliation happens once at the incoming-data boundary. +final class MobileConversationRow: Identifiable, Equatable { let id: String let kind: String let text: String @@ -94,6 +187,82 @@ struct MobileConversationRow: Identifiable, Equatable { let pending: Bool let showRetry: Bool let error: String? + let live: Bool + + init(id: String, kind: String, text: String, thinking: String?, images: [MobileTimelineImage], + tools: [MobileTimelineTool], blocks: [MobileTimelineBlock], streaming: Bool, typing: Bool, + pending: Bool, showRetry: Bool, error: String?, live: Bool = false) { + self.id = nativeTimelineString(id) + self.kind = nativeTimelineString(kind) + self.text = nativeTimelineString(text) + self.thinking = thinking.map(nativeTimelineString) + self.images = images.map { MobileTimelineImage(name: nativeTimelineString($0.name), dataURL: nativeTimelineString($0.dataURL)) } + self.tools = tools.map(nativeTimelineTool) + self.blocks = blocks.map(nativeTimelineBlock) + self.streaming = streaming + self.typing = typing + self.pending = pending + self.showRetry = showRetry + self.error = error.map(nativeTimelineString) + self.live = live + } + + static func == (lhs: MobileConversationRow, rhs: MobileConversationRow) -> Bool { lhs === rhs } + + private func matchesContent(of other: MobileConversationRow) -> Bool { + if self === other { return true } + return id == other.id && kind == other.kind && text == other.text && thinking == other.thinking && + images == other.images && tools == other.tools && blocks == other.blocks && + streaming == other.streaming && typing == other.typing && pending == other.pending && + showRetry == other.showRetry && error == other.error && live == other.live + } + + static func reconcile(_ incoming: [MobileConversationRow], with current: [MobileConversationRow]) -> [MobileConversationRow] { + var previous: [String: MobileConversationRow] = [:] + for row in current { previous[row.id] = row } + return incoming.map { row in + guard let old = previous[row.id], old.matchesContent(of: row) else { return row } + return old + } + } +} + +// Kotlin strings arrive as NSString-backed Swift strings. Materialize native UTF-8 +// once, so reconciliation cannot repeatedly enter foreign Unicode scalar access. +private func nativeTimelineString(_ value: String) -> String { + var value = value + value.makeContiguousUTF8() + return value +} + +private func nativeTimelineTool(_ tool: MobileTimelineTool) -> MobileTimelineTool { + MobileTimelineTool(id: nativeTimelineString(tool.id), name: nativeTimelineString(tool.name), + phase: nativeTimelineString(tool.phase), kind: nativeTimelineString(tool.kind), + operation: nativeTimelineString(tool.operation), target: nativeTimelineString(tool.target), + filePath: nativeTimelineString(tool.filePath), fileLabel: nativeTimelineString(tool.fileLabel), + input: nativeTimelineString(tool.input), output: nativeTimelineString(tool.output), + question: tool.question.map(nativeTimelineString), questions: tool.questions.map { question in + MobileTimelineQuestion(index: question.index, header: nativeTimelineString(question.header), + question: nativeTimelineString(question.question), options: question.options.map { + MobileTimelineOption(label: nativeTimelineString($0.label), description: $0.description.map(nativeTimelineString)) + }, multiSelect: question.multiSelect) + }, actions: Set(tool.actions.map(nativeTimelineString)), foldIntoSummary: tool.foldIntoSummary, + planPath: tool.planPath.map(nativeTimelineString), planName: nativeTimelineString(tool.planName), + planOverview: nativeTimelineString(tool.planOverview)) +} + +private func nativeTimelineBlock(_ block: MobileTimelineBlock) -> MobileTimelineBlock { + switch block { + case let .text(id, text, streaming): + return .text(id: nativeTimelineString(id), text: nativeTimelineString(text), streaming: streaming) + case let .thinking(id, text, streaming): + return .thinking(id: nativeTimelineString(id), text: nativeTimelineString(text), streaming: streaming) + case let .tools(id, tools): + return .tools(id: nativeTimelineString(id), tools: tools.map(nativeTimelineTool)) + case let .subagent(id, title, running, text, children, status): + return .subagent(id: nativeTimelineString(id), title: nativeTimelineString(title), running: running, + text: nativeTimelineString(text), children: children.map(nativeTimelineBlock), status: nativeTimelineString(status)) + } } enum MobileFilePreviewFailureKind: String { @@ -207,6 +376,7 @@ struct PendingDirectoryRemoteDraft { let rawDeviceKey: String let workspacePath: String let normalizedWorkspacePath: String + var agentType: String = "code" let epoch: UInt64 var selectionRequested: Bool } @@ -294,3 +464,10 @@ enum MobileDownloadPhase { case saved case failed } + +struct PendingComposerSend { + let sessionID: String + let text: String + let images: [ComposerAttachment] + let previousAckID: String? +} diff --git a/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings b/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings index 2f7a0cd21e..b77a347917 100644 --- a/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings +++ b/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings @@ -1,6 +1,26 @@ { "sourceLanguage": "zh-Hans", "strings": { + "查看设备": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browse devices" + } + } + } + }, + "选择设备和工作区,继续你的对话。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a device and workspace to continue your conversations." + } + } + } + }, "无法打开授权页面,请重试。": { "localizations": { "en": { @@ -2261,6 +2281,16 @@ } } }, + "子任务输出": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Subtask output" + } + } + } + }, "输出": { "localizations": { "en": { diff --git a/src/apps/mobile/ios/OpenBitFunUITests/RemoteCodeSessionSendUITests.swift b/src/apps/mobile/ios/OpenBitFunUITests/RemoteCodeSessionSendUITests.swift index 52c204222a..426c674b9b 100644 --- a/src/apps/mobile/ios/OpenBitFunUITests/RemoteCodeSessionSendUITests.swift +++ b/src/apps/mobile/ios/OpenBitFunUITests/RemoteCodeSessionSendUITests.swift @@ -926,3 +926,427 @@ private extension XCUIApplication { if later.exists { later.tap() } } } + +final class StreamingPresentationUITests: XCTestCase { + func testDirectoryOpenRoutesBeforeAuthorityIsReady() { + let app = XCUIApplication() + app.launchArguments = ["--streaming-regression", "--fixture-shell", "--open-loading-regression", "--status-regression"] + app.launch() + let open = app.buttons["fixture.openDelayed"] + XCTAssertTrue(open.waitForExistence(timeout: 15)) + let status = app.descendants(matching: .any)["conversation.connectionStatus"].firstMatch + XCTAssertTrue(status.exists) + XCTAssertEqual(status.frame.height, 48, accuracy: 1) + open.tap() + let detail = app.descendants(matching: .any)["conversation.session.delayed"].firstMatch + XCTAssertTrue(detail.waitForExistence(timeout: 2)) + let loading = app.descendants(matching: .any)["conversation.loading"].firstMatch + XCTAssertTrue(loading.waitForExistence(timeout: 2)) + XCTAssertFalse(status.exists, "The connection strip must not compete with the loading skeleton") + app.buttons["fixture.bindTarget"].tap() + XCTAssertTrue(detail.exists, "Binding the selected device must preserve pending navigation") + XCTAssertTrue(loading.waitForExistence(timeout: 2)) + app.buttons["fixture.finishLoading"].tap() + XCTAssertTrue(app.staticTexts["LOADED-SESSION"].waitForExistence(timeout: 3)) + XCTAssertFalse(loading.exists) + } + + func testSubagentDetailsMatchHarmonyPreviewAndThinking() { + let app = XCUIApplication() + app.launchArguments = ["--streaming-regression", "--card-regression", "--subagent-detail-regression"] + app.launch() + let task = app.buttons["subagent.toggle.details"] + XCTAssertTrue(task.waitForExistence(timeout: 15)) + XCTAssertFalse(app.staticTexts["LIVE-CHILD-THOUGHT"].exists) + XCTAssertFalse(app.buttons["subagent.toggle.empty"].isEnabled, "Empty tasks must not offer disclosure") + task.tap() + XCTAssertTrue(app.staticTexts["LIVE-CHILD-THOUGHT"].waitForExistence(timeout: 3)) + XCTAssertFalse(app.staticTexts["OLD-CHILD-THOUGHT"].exists) + XCTAssertFalse(app.staticTexts["PARENT-SUMMARY-MUST-NOT-REPEAT"].exists) + let preview = app.staticTexts["subagent.output.child-output"] + XCTAssertTrue(preview.exists) + XCTAssertLessThanOrEqual(preview.label.count, 321) + XCTAssertFalse(preview.label.contains("HIDDEN-OUTPUT-TAIL")) + XCTAssertLessThan(preview.frame.height, 100) + XCTAssertTrue(app.buttons["tool.toggle.child-one"].exists) + XCTAssertTrue(app.buttons["tool.toggle.child-two"].exists) + XCTAssertFalse(app.buttons["tool.summary.child-one"].exists) + XCTAssertFalse(app.staticTexts["NESTED-OUTPUT"].exists) + app.buttons["subagent.toggle.nested"].tap() + XCTAssertTrue(app.staticTexts["NESTED-OUTPUT"].exists) + app.buttons["subagent.toggle.nested"].tap() + app.buttons["fixture.send"].tap() + XCTAssertTrue(task.label.contains("失败"), "Host failures must remain visible in the subtask header") + XCTAssertTrue(preview.exists, "Completing keeps the manually opened subtask open") + XCTAssertFalse(app.staticTexts["LIVE-CHILD-THOUGHT"].exists) + app.buttons["thinking.toggle.child-old"].tap() + XCTAssertTrue(app.staticTexts["OLD-CHILD-THOUGHT"].exists) + } + + func testCardsMatchHarmonyExpansionAndOrdering() { + let app = XCUIApplication() + app.launchArguments = ["--streaming-regression", "--card-regression"] + app.launch() + let task = app.buttons["subagent.toggle.task"] + XCTAssertTrue(task.waitForExistence(timeout: 15)) + XCTAssertFalse(app.staticTexts["TASK-BODY"].exists, "Running tasks start collapsed") + task.tap() + XCTAssertTrue(app.staticTexts["TASK-BODY"].waitForExistence(timeout: 3)) + app.buttons["fixture.send"].tap() + XCTAssertTrue(app.staticTexts["TASK-BODY"].exists, "Status updates preserve the user's expansion") + task.tap() + XCTAssertFalse(app.staticTexts["TASK-BODY"].exists) + let summary = app.buttons["tool.summary.one"] + XCTAssertTrue(summary.exists) + XCTAssertFalse(app.buttons["tool.toggle.one"].exists) + XCTAssertFalse(app.staticTexts["REASON-BEFORE"].exists) + XCTAssertFalse(app.buttons["thinking.toggle.before"].exists) + XCTAssertTrue(app.buttons["tool.toggle.running"].exists) + XCTAssertTrue(app.buttons["tool.toggle.failed"].exists) + summary.tap() + let one = app.buttons["tool.toggle.one"] + let two = app.buttons["tool.toggle.two"] + XCTAssertTrue(one.waitForExistence(timeout: 3)) + let before = app.buttons["thinking.toggle.before"] + let between = app.buttons["thinking.toggle.between"] + XCTAssertTrue(before.exists && between.exists) + XCTAssertLessThan(before.frame.minY, one.frame.minY) + XCTAssertLessThan(one.frame.minY, between.frame.minY) + XCTAssertLessThan(between.frame.minY, two.frame.minY) + one.tap() + XCTAssertTrue(app.staticTexts["OUTPUT-one"].waitForExistence(timeout: 3)) + two.tap() + XCTAssertTrue(app.staticTexts["OUTPUT-two"].waitForExistence(timeout: 3)) + XCTAssertFalse(app.staticTexts["OUTPUT-one"].exists, "Only one tool detail is expanded") + XCTAssertLessThan(one.frame.minY, two.frame.minY) + app.buttons["fixture.send"].tap() + XCTAssertTrue(app.buttons["tool.toggle.three"].waitForExistence(timeout: 3), + "Appending a completed tool must preserve the open summary") + XCTAssertTrue(app.staticTexts["OUTPUT-two"].exists, + "Appending tools must preserve the selected detail") + summary.tap() + XCTAssertFalse(one.exists) + XCTAssertFalse(app.staticTexts["OUTPUT-two"].exists) + XCTAssertTrue(app.staticTexts["ANSWER-AFTER-ACTIVITY"].isHittable) + let capture = XCTAttachment(screenshot: app.screenshot()) + capture.lifetime = .keepAlways + add(capture) + } + + func testSentUserBubbleStaysVisibleBeforeReply() { + let app = XCUIApplication() + app.launchArguments = ["--streaming-regression", "--user-bubble-regression"] + app.launch() + let start = app.buttons["fixture.send"] + XCTAssertTrue(start.waitForExistence(timeout: 15)) + let input = app.textFields.firstMatch + input.tap() + input.typeText("Message before reply") + start.tap() + let bubble = app.staticTexts["SENT-USER-BUBBLE"] + XCTAssertTrue(bubble.waitForExistence(timeout: 5)) + for _ in 0..<4 { + XCTAssertTrue(bubble.isHittable, "The sent message must remain visible while awaiting a reply") + let tick = expectation(description: "Observe pending/acknowledged user bubble") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { tick.fulfill() } + wait(for: [tick], timeout: 2) + } + XCTAssertTrue(app.staticTexts["Stream finished"].waitForExistence(timeout: 15)) + XCTAssertTrue(bubble.isHittable) + XCTAssertTrue(app.staticTexts["SHORT-REPLY"].isHittable) + let capture = XCTAttachment(screenshot: app.screenshot()) + capture.lifetime = .keepAlways + add(capture) + } + + func testLongThinkingFoldsWhenAnswerStarts() { + let app = XCUIApplication() + app.launchArguments = ["--streaming-regression", "--thinking-regression"] + app.launch() + let start = app.buttons["fixture.send"] + XCTAssertTrue(start.waitForExistence(timeout: 15)) + start.tap() + let thought = app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Thinking segment 12")).firstMatch + XCTAssertTrue(thought.waitForExistence(timeout: 10)) + XCTAssertGreaterThan(thought.frame.height, app.scrollViews.firstMatch.frame.height) + let answer = app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "ANSWER-AFTER-THINKING")).firstMatch + XCTAssertTrue(answer.waitForExistence(timeout: 10)) + let folded = NSPredicate { _, _ in !thought.exists } + expectation(for: folded, evaluatedWith: nil) + waitForExpectations(timeout: 5) + XCTAssertTrue(answer.isHittable) + XCTAssertTrue(app.staticTexts["Stream finished"].waitForExistence(timeout: 10)) + XCTAssertTrue(answer.isHittable) + let capture = XCTAttachment(screenshot: app.screenshot()) + capture.lifetime = .keepAlways + add(capture) + } + + override func setUpWithError() throws { continueAfterFailure = false } + + func testPrependingHistoryPreservesVisibleRowOffset() { + let app = XCUIApplication() + app.launchArguments = ["--streaming-regression"] + app.launch() + let short = app.buttons["fixture.shortHistory"] + XCTAssertTrue(short.waitForExistence(timeout: 15)) + short.tap() + let load = app.buttons["timeline.loadOlder"] + XCTAssertTrue(load.waitForExistence(timeout: 5)) + let row = app.staticTexts["History row 0"] + XCTAssertTrue(row.waitForExistence(timeout: 5)) + let originalY = row.frame.minY + print("History anchor before: \(originalY)") + let before = XCTAttachment(screenshot: app.screenshot()) + before.name = "BeforePrepend" + before.lifetime = .keepAlways + add(before) + load.tap() + let after = XCTAttachment(screenshot: app.screenshot()) + after.name = "AfterPrepend" + after.lifetime = .keepAlways + add(after) + let restored = NSPredicate { _, _ in + print("History anchor observed: exists=\(row.exists) y=\(row.frame.minY) expected=\(originalY)") + return row.exists && abs(row.frame.minY - originalY) < 4 + } + expectation(for: restored, evaluatedWith: nil) + waitForExpectations(timeout: 5) + XCTAssertFalse(load.exists) + } + + func testUserCanReadHistoryAndResumeFollowingDuringStreaming() { + let app = XCUIApplication() + app.launchArguments = ["--streaming-regression"] + app.launch() + let start = app.buttons["fixture.send"] + XCTAssertTrue(start.waitForExistence(timeout: 15)) + start.tap() + let scroll = app.scrollViews.firstMatch + XCTAssertTrue(scroll.waitForExistence(timeout: 5)) + scroll.swipeDown() + scroll.swipeDown() + let follow = app.buttons["timeline.scrollToBottom"] + XCTAssertTrue(follow.waitForExistence(timeout: 5)) + follow.tap() + XCTAssertTrue(app.staticTexts["Stream finished"].waitForExistence(timeout: 25)) + let end = app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "STREAM-END")).firstMatch + XCTAssertTrue(end.waitForExistence(timeout: 5)) + XCTAssertTrue(end.isHittable) + app.buttons["fixture.reset"].tap() + XCTAssertTrue(start.exists) + XCTAssertTrue(app.textFields.firstMatch.exists) + expectation(for: NSPredicate { _, _ in !end.exists }, evaluatedWith: nil) + waitForExpectations(timeout: 5) + } + + func testKeyboardDismissalAndLongStreamKeepContentVisible() { + checkKeyboardAndStream(shell: false) + } + + func testFullShellRemainsVisibleThroughKeyboardAndStreaming() { + checkKeyboardAndStream(shell: true) + } + + private func checkKeyboardAndStream(shell: Bool) { + let app = XCUIApplication() + app.launchArguments = ["--streaming-regression"] + (shell ? ["--fixture-shell"] : []) + app.launch() + let start = app.buttons["fixture.send"] + XCTAssertTrue(start.waitForExistence(timeout: 15)) + let input = app.textFields.firstMatch + XCTAssertTrue(input.waitForExistence(timeout: 10)) + input.tap() + input.typeText("Draft before streaming") + XCTAssertTrue(app.keyboards.firstMatch.exists) + start.tap() + let keyboardGone = NSPredicate { _, _ in !app.keyboards.firstMatch.exists } + expectation(for: keyboardGone, evaluatedWith: nil) + waitForExpectations(timeout: 5) + XCTAssertTrue(start.exists) + XCTAssertTrue(input.exists) + XCTAssertTrue(app.staticTexts["Stream finished"].waitForExistence(timeout: 25)) + let end = app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "STREAM-END")).firstMatch + XCTAssertTrue(end.waitForExistence(timeout: 5)) + XCTAssertTrue(end.isHittable, "The final response should remain in the viewport") + let capture = XCTAttachment(screenshot: app.screenshot()) + capture.lifetime = .keepAlways + add(capture) + } +} + +/// Real-device diagnostics. Sending probes require an explicit environment opt-in. +final class RemoteTimelineScrollProbeUITests: XCTestCase { + func testStreamInCurrentConversation() throws { + guard ProcessInfo.processInfo.environment["PROBE_SEND_CURRENT"] == "1" else { + throw XCTSkip("Explicit live send probe is not enabled") + } + let app = XCUIApplication(bundleIdentifier: "com.openbitfun.mobile.ios") + app.activate() + let conversation = app.descendants(matching: .any).matching( + NSPredicate(format: "identifier BEGINSWITH %@", "conversation.session.") + ).firstMatch + XCTAssertTrue(conversation.waitForExistence(timeout: 10)) + let input = app.textFields["composer.input"] + XCTAssertTrue(input.exists) + input.tap() + input.typeText("请写一篇约1500字的中文说明,分成十段,介绍聊天界面应有的交互体验。不要调用工具或修改文件。这是流式滚动诊断测试。") + app.buttons["arrow.up"].tap() + for index in 0..<6 { + let pause = expectation(description: "Allow streaming progress") + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { pause.fulfill() } + wait(for: [pause], timeout: 3) + let capture = XCTAttachment(screenshot: app.screenshot()) + capture.name = "StreamPhase\(index)" + capture.lifetime = .keepAlways + add(capture) + if index == 2 || index == 4 { + let scroll = conversation.scrollViews.firstMatch + scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.25)) + .press(forDuration: 0.1, thenDragTo: scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8))) + } + } + let hierarchy = XCTAttachment(string: app.debugDescription) + hierarchy.name = "AfterStreamingDrag" + hierarchy.lifetime = .keepAlways + add(hierarchy) + } + + func testInspectCurrentScreen() { + let app = XCUIApplication(bundleIdentifier: "com.openbitfun.mobile.ios") + app.activate() + if app.buttons["查看设备"].waitForExistence(timeout: 3) { app.buttons["查看设备"].tap() } + let device = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH %@", "sidebar.device.")).firstMatch + if !device.waitForExistence(timeout: 8), app.buttons["查看设备"].exists { + app.buttons["查看设备"].tap() + _ = device.waitForExistence(timeout: 8) + } + if device.exists && !device.isHittable && app.buttons["打开侧栏"].exists { + app.buttons["打开侧栏"].tap() + } + if device.exists && device.isHittable { + device.tap() + let workspace = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH %@", "sidebar.workspace.")).firstMatch + _ = workspace.waitForExistence(timeout: 20) + } + if let sessionID = ProcessInfo.processInfo.environment["PROBE_SESSION_ID"] { + let session = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH %@ AND identifier ENDSWITH %@", "sidebar.session.", sessionID)).firstMatch + let workspaces = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH %@", "sidebar.workspace.")) + for workspace in workspaces.allElementsBoundByIndex { + if session.exists { break } + if workspace.isHittable { workspace.tap(); _ = session.waitForExistence(timeout: 5) } + } + if session.exists && session.isHittable { + session.tap() + let input = app.textFields["composer.input"] + if input.waitForExistence(timeout: 15) { + let message = ProcessInfo.processInfo.environment["PROBE_MESSAGE"] ?? "请仅用三段简短中文介绍你能做什么。不要调用工具或修改任何文件。这是一条手机流式显示测试消息。" + input.tap() + if (input.value as? String) != message { input.typeText(message) } + let send = app.buttons["arrow.up"] + if send.exists && send.isEnabled { + send.tap() + let sent = XCTAttachment(screenshot: app.screenshot()) + sent.name = "ImmediatelyAfterSend" + sent.lifetime = .keepAlways + add(sent) + let draftCleared = NSPredicate { _, _ in (input.value as? String) != message } + _ = XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: draftCleared, object: nil)], timeout: 10) + let settled = NSPredicate { _, _ in !app.buttons["停止"].exists } + _ = XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: settled, object: nil)], timeout: 45) + let conversation = app.descendants(matching: .any).matching(NSPredicate(format: "identifier BEGINSWITH %@", "conversation.session.")).firstMatch + let scroll = conversation.scrollViews.firstMatch + if scroll.exists { + let beforeDrag = XCTAttachment(string: app.debugDescription) + beforeDrag.name = "AfterReplyBeforeDrag" + beforeDrag.lifetime = .keepAlways + add(beforeDrag) + scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.25)).press(forDuration: 0.1, thenDragTo: scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8))) + } + } + } + } + } + let hierarchy = XCTAttachment(string: app.debugDescription) + hierarchy.name = "CurrentScreen" + hierarchy.lifetime = .keepAlways + add(hierarchy) + } + + func testProfileCurrentComposerInteractions() { + let app = XCUIApplication(bundleIdentifier: "com.openbitfun.mobile.ios") + app.activate() + let header = app.buttons["打开侧栏"] + if header.exists && header.frame.minX > 100 { header.tap() } + let input = app.textFields["composer.input"] + if !input.waitForExistence(timeout: 10) { + if app.buttons["查看设备"].exists { app.buttons["查看设备"].tap() } + let device = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH %@", "sidebar.device.")).firstMatch + if !device.isHittable && header.exists { header.tap() } + XCTAssertTrue(device.waitForExistence(timeout: 10)) + device.tap() + let workspaces = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH %@", "sidebar.workspace.")) + XCTAssertTrue(workspaces.firstMatch.waitForExistence(timeout: 20)) + guard let sessionID = ProcessInfo.processInfo.environment["PROBE_SESSION_ID"] else { + XCTFail("A session ID is required to open a closed conversation"); return + } + let session = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH %@ AND identifier ENDSWITH %@", "sidebar.session.", sessionID)).firstMatch + for workspace in workspaces.allElementsBoundByIndex { + if session.exists { break } + if workspace.isHittable { workspace.tap(); _ = session.waitForExistence(timeout: 5) } + } + XCTAssertTrue(session.waitForExistence(timeout: 10)) + session.tap() + } + XCTAssertTrue(input.waitForExistence(timeout: 10)) + for cycle in 0..<6 { + let pause = expectation(description: "Prepare interaction profiling") + DispatchQueue.main.asyncAfter(deadline: .now() + 4) { pause.fulfill() } + wait(for: [pause], timeout: 6) + print("ComposerProbe cycle=\(cycle) action=focus begin") + input.tap() + let picker = app.buttons["选择模型"] + XCTAssertTrue(picker.waitForExistence(timeout: 10)) + print("ComposerProbe cycle=\(cycle) action=model-picker begin") + picker.tap() + let close = app.buttons["关闭"].firstMatch + XCTAssertTrue(close.waitForExistence(timeout: 10)) + close.tap() + let conversation = app.descendants(matching: .any).matching(NSPredicate(format: "identifier BEGINSWITH %@", "conversation.session.")).firstMatch + conversation.scrollViews.firstMatch.swipeDown() + print("ComposerProbe cycle=\(cycle) action=finished") + } + } + + func testCurrentConversationCanScroll() throws { + let app = XCUIApplication(bundleIdentifier: "com.openbitfun.mobile.ios") + app.activate() + let before = XCTAttachment(string: app.debugDescription) + before.name = "RemoteTimelineBeforeDrag" + before.lifetime = .keepAlways + add(before) + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.name = "RemoteTimelineBeforeDrag" + screenshot.lifetime = .keepAlways + add(screenshot) + let conversation = app.descendants(matching: .any).matching( + NSPredicate(format: "identifier BEGINSWITH %@", "conversation.session.") + ).firstMatch + let scroll = conversation.scrollViews.firstMatch + guard scroll.waitForExistence(timeout: 10) else { + XCTFail("No conversation scroll view is available") + return + } + let start = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.25)) + let end = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)) + for _ in 0..<12 { start.press(forDuration: 0.1, thenDragTo: end) } + let after = XCTAttachment(string: app.debugDescription) + after.name = "RemoteTimelineAfterDrag" + after.lifetime = .keepAlways + add(after) + let afterScreenshot = XCTAttachment(screenshot: app.screenshot()) + afterScreenshot.name = "RemoteTimelineAfterDrag" + afterScreenshot.lifetime = .keepAlways + add(afterScreenshot) + } +} diff --git a/src/apps/mobile/ios/Testing/MobileProcessGroupTests.swift b/src/apps/mobile/ios/Testing/MobileProcessGroupTests.swift new file mode 100644 index 0000000000..7f439359a0 --- /dev/null +++ b/src/apps/mobile/ios/Testing/MobileProcessGroupTests.swift @@ -0,0 +1,96 @@ +import Foundation + +@main +struct MobileProcessGroupTests { + static func tool(_ id: String, fold: Bool = true) -> MobileTimelineTool { + MobileTimelineTool(id: id, name: "Read", phase: fold ? "COMPLETED" : "RUNNING", + kind: "FILE", operation: "READ", target: id, filePath: "", fileLabel: "", input: "", output: "", + question: nil, questions: [], actions: [], foldIntoSummary: fold) + } + + static func snapshotTests() { + func row(_ id: String = "row", text: String = "body", blocks: [MobileTimelineBlock] = [], + tools: [MobileTimelineTool] = [], pending: Bool = false, live: Bool = false, + error: String? = nil) -> MobileConversationRow { + MobileConversationRow(id: id, kind: "ASSISTANT", text: text, thinking: nil, + images: [], tools: tools, blocks: blocks, streaming: live, typing: false, + pending: pending, showRetry: error != nil, error: error, live: live) + } + let original = row() + let same = row() + precondition(original != same, "View equality must compare identity without scanning content") + precondition(MobileConversationRow.reconcile([same], with: [original])[0] === original) + for changed in [row(text: "edited"), row(pending: true), row(live: true), row(error: "failed"), + row(blocks: [.thinking(id: "thought", text: "Updated", streaming: true)]), + row(tools: [tool("new")])] { + precondition(MobileConversationRow.reconcile([changed], with: [original])[0] === changed, + "Every presentation change must invalidate the snapshot") + } + let oldTask = row(blocks: [.subagent(id: "task", title: "Task", running: true, text: "", + children: [.thinking(id: "reason", text: "first", streaming: true)], status: "running")]) + let newTask = row(blocks: [.subagent(id: "task", title: "Task", running: true, text: "", + children: [.thinking(id: "reason", text: "second", streaming: true)], status: "running")]) + precondition(MobileConversationRow.reconcile([newTask], with: [oldTask])[0] === newTask) + let previous = [row("one"), row("two")] + let incoming = [row("older"), row("one"), row("two", text: "streamed")] + let merged = MobileConversationRow.reconcile(incoming, with: previous) + precondition(merged.map(\.id) == ["older", "one", "two"] && merged[1] === previous[0] && merged[2] === incoming[2]) + precondition(MobileConversationRow.reconcile([], with: previous).isEmpty) + let cocoa = NSMutableString(capacity: 10000) + for _ in 0..<100 { cocoa.append("远程消息 café 👩🏽‍💻\n") } + let bridged = cocoa.copy() as! NSString as String + let snapshot = row(text: bridged) + precondition(snapshot.text == bridged && snapshot.text.isContiguousUTF8) + precondition(MobileConversationRow.reconcile([row(text: bridged)], with: [snapshot])[0] === snapshot) + print("Immutable timeline snapshot tests passed") + } + + static func main() { + snapshotTests() + let thought = MobileTimelineBlock.thinking(id: "reason", text: "Before", streaming: false) + let first = MobileTimelineBlock.tools(id: "tools1", tools: [tool("one")]) + let between = MobileTimelineBlock.thinking(id: "between", text: "Between", streaming: false) + let last = MobileTimelineBlock.tools(id: "tools2", tools: [tool("two")]) + let answer = MobileTimelineBlock.text(id: "answer", text: "Answer", streaming: true) + let grouped = MobileProcessGroup.project([thought, first, between, last, answer]) + precondition(grouped.count == 2 && grouped[0].hasSummary && !grouped[1].hasSummary) + precondition(grouped[0].blocks.map(\.id) == ["reason", "tool-one", "between", "tool-two"]) + precondition(grouped[1].blocks == [answer]) + precondition(!MobileProcessGroup.project([thought, first])[0].hasSummary, + "Reasoning must remain accessible when there is no summary to expand") + let live = MobileTimelineBlock.tools(id: "live", tools: [tool("running", fold: false)]) + let blocked = MobileProcessGroup.project([thought, first, live, between, last]) + precondition(blocked.count == 3 && blocked.allSatisfy { !$0.hasSummary }, + "Running/failed/approval/question/plan tools must break completed activity groups") + let child = MobileTimelineBlock.subagent(id: "task", title: "Task", running: true, text: "", children: []) + precondition(MobileProcessGroup.project([first, child, last]).count == 3) + let growing = MobileProcessGroup.project([thought, first, between, last, + .tools(id: "more", tools: [tool("three")])]) + precondition(growing[0].id == grouped[0].id, "Stream growth must not reset the expansion identity") + let adjacent = MobileProcessGroup.project([thought, + .thinking(id: "reason2", text: "More", streaming: true), first]) + guard case let .thinking(id, text, streaming) = adjacent[0].blocks[0] else { fatalError() } + precondition(id == "reason" && text == "Before\n\nMore" && streaming) + let mixedTools = MobileProcessGroup.project([.tools(id: "mixed", tools: [tool("one"), tool("two"), tool("wait", fold: false), tool("three")])]) + precondition(mixedTools.count == 1 && mixedTools[0].toolsOnly && !mixedTools[0].hasSummary) + precondition(mixedTools[0].tools.map(\.id) == ["one", "two", "wait", "three"], + "A contiguous plain tool list shares its disclosure selection across summary and independent rows") + for status in ["failed", "error", "timeout", "cancelled", "canceled", "rejected"] { + precondition(MobileSubagentPresentation.failed(status)) + } + precondition(!MobileSubagentPresentation.failed("completed")) + let children: [MobileTimelineBlock] = [ + .thinking(id: "old", text: "Old", streaming: true), + first, answer, .thinking(id: "tail", text: "Current", streaming: false)] + let runningChildren = MobileSubagentPresentation.blocks(children, running: true) + precondition(runningChildren[0] == .thinking(id: "old", text: "Old", streaming: false)) + precondition(runningChildren.last == .thinking(id: "tail", text: "Current", streaming: true)) + precondition(MobileSubagentPresentation.blocks(children, running: false).last == + .thinking(id: "tail", text: "Current", streaming: false)) + precondition(MobileSubagentPresentation.blocks([.tools(id: "both", tools: [tool("one"), tool("two")])], running: true).count == 2) + precondition(MobileSubagentPresentation.preview(" short ") == "short") + let preview = MobileSubagentPresentation.preview(String(repeating: "x", count: 400)) + precondition(preview.count == 321 && preview.hasSuffix("…")) + print("Mobile process grouping tests passed") + } +} diff --git a/src/apps/mobile/ios/Testing/RemoteAuthorityGateTests.swift b/src/apps/mobile/ios/Testing/RemoteAuthorityGateTests.swift index 694971d87c..cd9d4a7d7e 100644 --- a/src/apps/mobile/ios/Testing/RemoteAuthorityGateTests.swift +++ b/src/apps/mobile/ios/Testing/RemoteAuthorityGateTests.swift @@ -583,6 +583,56 @@ struct RemoteAuthorityGateTests { message: "terminal authority loss clears the complete target projection before dropping model authority" ) + for (target, storeDevice) in [("account:device-a", "device-a" as String?), ("pairing:session-a", nil)] { + expect(RemoteAuthorityGate.filePreviewCallbackMatchesAuthority( + requestTargetKey: target, requestEpoch: 7, + adapterTargetKey: target, adapterEpoch: 7, + expectedStoreDeviceKey: storeDevice, callbackDeviceKey: storeDevice + ), "preview accepts store identity independently of the adapter namespace") + expect(!RemoteAuthorityGate.filePreviewCallbackMatchesAuthority( + requestTargetKey: target, requestEpoch: 7, + adapterTargetKey: target, adapterEpoch: 8, + expectedStoreDeviceKey: storeDevice, callbackDeviceKey: storeDevice + ), "rebound stores cannot update an old preview") + expect(!RemoteAuthorityGate.filePreviewCallbackMatchesAuthority( + requestTargetKey: target, requestEpoch: 7, + adapterTargetKey: target, adapterEpoch: 7, + expectedStoreDeviceKey: storeDevice, callbackDeviceKey: "different-device" + ), "another device's preview is rejected") + } + + expect(ComposerSendSettlementPolicy.shouldRestore( + sentSession: "a", currentSession: "a", acknowledged: false, + draftIsEmpty: true, attachmentsAreEmpty: true + ), "failed send restores the cleared composer") + for (session, ack, emptyDraft, emptyImages) in [ + ("a", true, true, true), ("b", false, true, true), + ("a", false, false, true), ("a", false, true, false) + ] { + expect(!ComposerSendSettlementPolicy.shouldRestore( + sentSession: "a", currentSession: session, acknowledged: ack, + draftIsEmpty: emptyDraft, attachmentsAreEmpty: emptyImages + ), "send settlement preserves newer typing, attachments and another session") + } + expectCallBeforeMutation( + in: remoteSessionSource, + function: "func sendRemote()", + call: "draft = \"\"", + mutation: "coreAdapter.sendRemote(", + message: "accepted send clears the composer before dispatching network work" + ) + + let selectionAdapterSource = readSource( + iosDirectory.appendingPathComponent("OpenBitFun/Infrastructure/MobileCoreAdapter.swift") + ) + expectCallBeforeMutation( + in: selectionAdapterSource, + function: "func selectAccountDevice(id: String)", + call: "onAccountState?(ready, accountGeneration)", + mutation: "startAccountRemoteSessionIfNeeded(", + message: "reselecting a persisted device acknowledges account readiness without waiting for a changed StateFlow" + ) + let filePreviewSource = readSource( iosDirectory.appendingPathComponent("OpenBitFun/Infrastructure/MobileAppModel+FilePreview.swift") ) diff --git a/src/apps/mobile/ios/Testing/RemoteHomePresentationTests.swift b/src/apps/mobile/ios/Testing/RemoteHomePresentationTests.swift new file mode 100644 index 0000000000..f6ee3f3af5 --- /dev/null +++ b/src/apps/mobile/ios/Testing/RemoteHomePresentationTests.swift @@ -0,0 +1,33 @@ +@main +struct RemoteHomePresentationTests { + static func main() { + // Exhaust the independent account, target, transport and directory facts. + // A previous target's loaded flag must never override target/connection state. + for signedIn in [false, true] { + for hasTarget in [false, true] { + for connected in [false, true] { + for reconnecting in [false, true] { + for loaded in [false, true] { + let state = RemoteHomePresentation.resolve( + signedIn: signedIn, hasTarget: hasTarget, connected: connected, + reconnecting: reconnecting, sessionsLoaded: loaded) + if !hasTarget { + precondition(state == (signedIn ? .chooseDevice : .pairDevice)) + } else if reconnecting { + precondition(state == .connecting) + } else if !connected { + precondition(state == .unavailable) + } else { + precondition(state == (loaded ? .ready : .loadingSessions)) + } + if state == .ready { + precondition(hasTarget && connected && !reconnecting && loaded) + } + } + } + } + } + } + print("Remote home presentation: all 32 state combinations passed.") + } +} diff --git a/src/apps/mobile/ios/Testing/StreamingTextStateTests.swift b/src/apps/mobile/ios/Testing/StreamingTextStateTests.swift new file mode 100644 index 0000000000..86ae3696ed --- /dev/null +++ b/src/apps/mobile/ios/Testing/StreamingTextStateTests.swift @@ -0,0 +1,42 @@ +import Foundation + +@main +struct StreamingTextStateTests { + @MainActor + static func main() { + var state = StreamingTextState() + let text = String(repeating: "中文👨‍👩‍👧‍👦é", count: 100) + state.update(text, active: true) + assert(state.visible.isEmpty) + for _ in 0..<35 { + let previous = state.visible + state.advance() + assert(state.visible.hasPrefix(previous)) + assert(text.hasPrefix(state.visible)) + } + assert(state.visible == text) + state.update("中文", active: true) + assert(state.visible == text && state.target == text) + state.update(text + " tail", active: true) + state.advance() + state.update("corrected", active: true) + assert(state.visible == "corrected") + state.update("final", active: false) + assert(state.visible == "final" && state.ticksRemaining == 0) + state.update("", active: false) + assert(state.visible.isEmpty) + let restored = StreamingTextState(text: text) + assert(restored.visible == text && restored.ticksRemaining == 0) + let cache = StreamingRevealCache() + cache.save("partial", for: "device-a|session-a|row-a|body") + assert(cache.text(for: "device-a|session-a|row-a|body") == "partial") + assert(cache.text(for: "device-b|session-a|row-a|body").isEmpty) + assert(cache.text(for: "device-a|session-b|row-a|body").isEmpty) + var resumed = StreamingTextState(text: cache.text(for: "device-a|session-a|row-a|body")) + resumed.update("partial remainder", active: true) + assert(resumed.visible == "partial") + resumed.advance() + assert(resumed.visible.hasPrefix("partial")) + print("Streaming text state tests passed") + } +} diff --git a/src/apps/mobile/ios/Testing/run-pure-swift-tests.sh b/src/apps/mobile/ios/Testing/run-pure-swift-tests.sh index 219f1cba45..0a5a9774c9 100755 --- a/src/apps/mobile/ios/Testing/run-pure-swift-tests.sh +++ b/src/apps/mobile/ios/Testing/run-pure-swift-tests.sh @@ -18,4 +18,23 @@ xcrun --sdk macosx swiftc \ -o "$OUTPUT_DIR/account-failure-copy-tests" "$OUTPUT_DIR/account-failure-copy-tests" +xcrun --sdk macosx swiftc \ + "$IOS_DIR/OpenBitFun/Infrastructure/RemoteHomePresentation.swift" \ + "$SCRIPT_DIR/RemoteHomePresentationTests.swift" \ + -o "$OUTPUT_DIR/remote-home-presentation-tests" +"$OUTPUT_DIR/remote-home-presentation-tests" + + +xcrun --sdk macosx swiftc \ + "$IOS_DIR/OpenBitFun/Infrastructure/StreamingTextState.swift" \ + "$SCRIPT_DIR/StreamingTextStateTests.swift" \ + -o "$OUTPUT_DIR/streaming-text-state-tests" +"$OUTPUT_DIR/streaming-text-state-tests" + +xcrun --sdk macosx swiftc \ + "$IOS_DIR/OpenBitFun/Presentation/Models/MobilePresentationModels.swift" \ + "$SCRIPT_DIR/MobileProcessGroupTests.swift" \ + -o "$OUTPUT_DIR/mobile-process-group-tests" +"$OUTPUT_DIR/mobile-process-group-tests" + printf '%s\n' 'iOS pure Swift focused 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 a1bda60f17..f16104d7ea 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 @@ -68,10 +68,20 @@ public class ChatTimelineStore public constructor() { state = state.copy(selectedModelId = selectedModelId) } + /** Reconcile a send even when the persisted message arrived before its acknowledgement. */ + public fun acknowledgeOptimisticTurn(localMessageId: String, turnId: String) { + if (localMessageId.isBlank() || turnId.isBlank()) return + val persisted = state.persistedMessages.any { it.role == "user" && it.turnId == turnId } + state = state.copy(optimisticMessages = state.optimisticMessages.mapNotNull { message -> + if (message.id != localMessageId) message + else if (persisted) null + else message.copy(turnId = turnId) + }) + } + public fun setPersistedMessages(messages: List) { - val persisted = realMessages(messages) - val previousIds = state.persistedMessages.mapTo(mutableSetOf()) { it.id } - val newlyPersisted = persisted.filterNot { it.id in previousIds } + val persisted = withoutProvisionalUserAliases(realMessages(messages)) + val newlyPersisted = newlyPersistedMessages(state.persistedMessages, persisted) val activeCovered = state.activeTurn?.let { active -> val coveredByContent = coveredByNewAssistantContent(active, newlyPersisted) if (coveredByContent) recentlyCoveredTurn = CoveredTurn.from(active) @@ -85,9 +95,8 @@ public class ChatTimelineStore public constructor() { } public fun mergePersistedMessages(messages: List) { - val incoming = realMessages(messages) - val previousIds = state.persistedMessages.mapTo(mutableSetOf()) { it.id } - val newlyPersisted = incoming.filterNot { it.id in previousIds } + val incoming = withoutProvisionalUserAliases(realMessages(messages)) + val newlyPersisted = newlyPersistedMessages(state.persistedMessages, incoming) val persisted = mergeMessages(state.persistedMessages, incoming) val activeCovered = state.activeTurn?.let { active -> val coveredByContent = coveredByNewAssistantContent(active, newlyPersisted) @@ -122,6 +131,16 @@ public class ChatTimelineStore public constructor() { if (normalizedTurnId.isEmpty()) return val activeId = "active-$normalizedTurnId" val existing = state.activeTurn + val acknowledgedTurn = emptyMessage(id = activeId, turnId = normalizedTurnId, status = "active") + // Fast hosts may finish and persist the reply before send_message returns. + // A late acknowledgement must not resurrect an already completed turn. + if (isActiveTurnCoveredByMessages(acknowledgedTurn, state.persistedMessages)) { + if (existing == null || isLocalPendingActiveTurn(existing) || existing.turnId == normalizedTurnId) { + state = state.copy(activeTurn = null, syncPhase = ChatSyncPhase.IDLE) + activeTurnAnchor = "" + } + return + } if (existing?.id == activeId) { val activeTurn = existing.copy( turnId = existing.turnId?.takeIf(String::isNotEmpty) ?: normalizedTurnId, @@ -313,6 +332,9 @@ public class ChatTimelineStore public constructor() { return activeTurn.takeUnless { isActiveTurnCoveredByMessages(it, state.persistedMessages) } } val previous = state.activeTurn + // Polls can still report idle while the send RPC is in flight. Only its + // acknowledgement/failure (or a real host turn) may settle this placeholder. + if (previous != null && isLocalPendingActiveTurn(previous)) return previous if (previous != null && MessageStatusSemantics.shouldHoldCompletedTurn(previous.status) && hasDisplayableAssistantFinal(previous) && !isActiveTurnCoveredByMessages(previous, state.persistedMessages) @@ -418,7 +440,7 @@ public class ChatTimelineStore public constructor() { status = incoming.status.ifEmpty { previous.status }, timestamp = incoming.timestamp ?: previous.timestamp, thinking = monotonicText(previous.thinking.orEmpty(), incoming.thinking.orEmpty()).ifEmpty { null }, - tools = incoming.tools ?: previous.tools, + tools = mergeTools(previous.tools, incoming.tools), items = mergeActiveItems(previous.items.orEmpty(), incoming.items.orEmpty()), images = incoming.images?.takeIf(List::isNotEmpty) ?: previous.images, ) @@ -670,6 +692,22 @@ public class ChatTimelineStore public constructor() { else -> previous } + private fun mergeTools( + previous: List?, + incoming: List?, + ): List? { + if (incoming.isNullOrEmpty()) return previous ?: incoming + if (previous.isNullOrEmpty()) return incoming + val merged = previous.toMutableList() + incoming.forEachIndexed { position, tool -> + val index = if (!tool.id.isNullOrBlank()) merged.indexOfFirst { it.id == tool.id } + else position.takeIf { it < merged.size && merged[it].id.isNullOrBlank() } ?: -1 + if (index < 0) merged += tool + else merged[index] = mergeTool(merged[index], tool)!! + } + return merged + } + private fun mergeTool( previous: RemoteToolStatusResponse?, incoming: RemoteToolStatusResponse?, @@ -677,7 +715,21 @@ public class ChatTimelineStore public constructor() { incoming == null -> previous previous == null -> incoming ToolStatusSemantics.shouldKeepPrevious(previous.status, incoming.status) -> previous - else -> incoming + else -> incoming.copy( + id = incoming.id ?: previous.id, + name = incoming.name ?: previous.name, + status = incoming.status ?: previous.status, + durationMs = incoming.durationMs ?: previous.durationMs, + startMs = incoming.startMs ?: previous.startMs, + inputPreview = incoming.inputPreview?.takeIf(String::isNotEmpty) ?: previous.inputPreview, + toolInput = incoming.toolInput ?: previous.toolInput, + stdout = incoming.stdout?.takeIf(String::isNotEmpty) ?: previous.stdout, + stderr = incoming.stderr?.takeIf(String::isNotEmpty) ?: previous.stderr, + toolOutput = incoming.toolOutput ?: previous.toolOutput, + resultPreview = incoming.resultPreview?.takeIf(String::isNotEmpty) ?: previous.resultPreview, + errorPreview = incoming.errorPreview?.takeIf(String::isNotEmpty) ?: previous.errorPreview, + exitCode = incoming.exitCode ?: previous.exitCode, + ) } private fun isLocalPendingActiveTurn(activeTurn: ChatMessage): Boolean = @@ -689,9 +741,15 @@ public class ChatTimelineStore public constructor() { ): List { val merged = current.toMutableList() incoming.forEach { message -> - val existingIndex = merged.indexOfFirst { it.id == message.id } + val existingIndex = merged.indexOfFirst { samePersistedMessage(it, message) } if (existingIndex >= 0) { - merged[existingIndex] = mergeMessageSnapshot(merged[existingIndex], message) + val previous = merged[existingIndex] + // A replayed provisional row must not replace the durable identity/body. + merged[existingIndex] = if (previous.id != message.id && isProvisionalUser(message)) { + previous + } else { + mergeMessageSnapshot(previous, message) + } } else { merged += message } @@ -699,6 +757,40 @@ public class ChatTimelineStore public constructor() { return merged } + private fun withoutProvisionalUserAliases(messages: List): List { + val durableTurns = messages.filter { it.role == "user" && !isProvisionalUser(it) } + .mapNotNullTo(mutableSetOf()) { it.turnId?.takeIf(String::isNotBlank) } + return messages.filterNot { isProvisionalUser(it) && it.turnId in durableTurns } + } + + private fun newlyPersistedMessages( + previous: List, + incoming: List, + ): List { + val ids = previous.mapTo(mutableSetOf()) { it.id } + val userTurns = previous.filter { it.role == "user" } + .mapNotNullTo(mutableSetOf()) { it.turnId?.takeIf(String::isNotBlank) } + val provisionalTurns = previous.filter(::isProvisionalUser).mapTo(mutableSetOf()) { it.turnId } + return incoming.filterNot { message -> + message.id in ids || (message.role == "user" && !message.turnId.isNullOrBlank() && + (message.turnId in provisionalTurns || (isProvisionalUser(message) && message.turnId in userTurns))) + } + } + + /** The host can expose a Turn placeholder before the original user message is persisted. */ + private fun isProvisionalUser(message: ChatMessage): Boolean = + message.role == "user" && !message.turnId.isNullOrBlank() && + message.id == "${message.turnId}-user" + + private fun samePersistedMessage(previous: ChatMessage, incoming: ChatMessage): Boolean { + if (previous.id == incoming.id) return true + // Only the known host-generated placeholder is an alias. Two durable + // user messages (including repeated steering) never merge by text or turn alone. + return previous.role == "user" && incoming.role == "user" && + !previous.turnId.isNullOrBlank() && previous.turnId == incoming.turnId && + (isProvisionalUser(previous) || isProvisionalUser(incoming)) + } + private fun mergeMessageSnapshot(previous: ChatMessage, incoming: ChatMessage): ChatMessage { val hasIncomingText = incoming.text.trim().isNotEmpty() return incoming.copy( @@ -758,6 +850,9 @@ public class ChatTimelineStore public constructor() { private fun isPersistedUserDuplicate(pending: ChatMessage, message: ChatMessage): Boolean { if (pending.role != "user" || message.role != "user") return false if (pending.id == message.id) return true + if (!pending.turnId.isNullOrBlank() && !message.turnId.isNullOrBlank()) { + return pending.turnId == message.turnId + } val pendingText = pending.text.trim() if (pendingText.isEmpty() || pendingText != message.text.trim()) return false return imageSignature(pending.images.orEmpty()) == imageSignature(message.images.orEmpty()) diff --git a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ToolInputPolicy.kt b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ToolInputPolicy.kt index a1cf69b460..aea66c05c1 100644 --- a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ToolInputPolicy.kt +++ b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ToolInputPolicy.kt @@ -32,6 +32,16 @@ public object ToolInputPolicy { * Empty rather than the tool name — which is what the source falls back to — * so the app can drop the separator instead of printing `Read file · Read`. */ + /** Full subtask title; truncation belongs to the native card's single-line layout. */ + public fun taskTitle(tool: RemoteToolStatusResponse): String { + val payload = payload(tool) + for (key in TASK_TITLE_KEYS) { + payload?.text(key)?.let { return it } + } + val preview = ToolStatusPolicy.inputText(tool).trim() + return preview.takeUnless(::looksLikeJson).orEmpty() + } + public fun summary(tool: RemoteToolStatusResponse): String { val payload = payload(tool) val fromPayload = summaryFromPayload(tool, payload) @@ -115,6 +125,7 @@ public object ToolInputPolicy { private val WHITESPACE = Regex("\\s+") private val LINE_SUFFIX = Regex(":\\d+(?:-\\d+)?$") + private val TASK_TITLE_KEYS = listOf("description", "task", "title", "prompt", "message", "task_name", "taskName", "name", "content") private val TASK_KEYS = listOf("description", "task", "prompt", "content") private val TARGET_KEYS = listOf( "description", 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 9be47a463f..1c08d3dc04 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 @@ -9,6 +9,131 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class ChatTimelineStoreTest { + @Test + fun oneAliasPairAcknowledgesOnlyOneRepeatedLegacySend() { + val store = ChatTimelineStore() + store.reset("s") + store.appendOptimisticMessage(message("local-1", "user", "same")) + store.appendOptimisticMessage(message("local-2", "user", "same")) + val provisional = message("t-user", "user", "same").copy(turnId = "t") + store.mergePersistedMessages(listOf(provisional, provisional.copy(id = "durable"))) + assertEquals(listOf("durable"), store.snapshot().persistedMessages.map { it.id }) + assertEquals(listOf("local-2"), store.snapshot().optimisticMessages.map { it.id }) + } + + @Test + fun durableUserReplacesLiveTurnPlaceholderBeforeReplyEnds() { + val store = ChatTimelineStore() + store.reset("s") + val provisional = message("turn-live-user", "user", "hello").copy(turnId = "turn-live") + val durable = provisional.copy(id = "user_remote_123") + store.mergePersistedMessages(listOf(provisional)) + store.setActiveTurn(message("active-turn-live", "assistant", "Thinking", "active").copy(turnId = "turn-live")) + store.mergePersistedMessages(listOf(durable)) + assertEquals(listOf(durable), store.snapshot().persistedMessages) + assertEquals(1, store.project(false).count { it.message?.role == "user" }) + assertTrue(store.snapshot().activeTurn != null) + store.mergePersistedMessages(listOf(provisional.copy(text = "stale"))) + assertEquals(listOf(durable), store.snapshot().persistedMessages) + store.setPersistedMessages(listOf(provisional, durable)) + assertEquals(listOf(durable), store.snapshot().persistedMessages) + store.setPersistedMessages(listOf(durable, provisional)) + assertEquals(listOf(durable), store.snapshot().persistedMessages) + } + + @Test + fun provisionalAliasReplayCannotConsumeAnotherIdenticalSend() { + val store = ChatTimelineStore() + store.reset("s") + val provisional = message("t-user", "user", "same").copy(turnId = "t") + store.setPersistedMessages(listOf(provisional.copy(id = "durable"))) + store.appendOptimisticMessage(message("local-new", "user", "same")) + store.mergePersistedMessages(listOf(provisional)) + assertEquals(listOf("local-new"), store.snapshot().optimisticMessages.map { it.id }) + } + + @Test + fun distinctDurableMessagesAndTurnsNeverCollapseByContent() { + val store = ChatTimelineStore() + store.reset("s") + val first = message("durable-1", "user", "same").copy(turnId = "t") + val second = first.copy(id = "durable-2") + val otherTurn = first.copy(id = "other-user", turnId = "other") + val legacy = first.copy(id = "t-user", turnId = null) + store.mergePersistedMessages(listOf(first, second, otherTurn, legacy)) + assertEquals(listOf(first, second, otherTurn, legacy), store.snapshot().persistedMessages) + } + + @Test + fun legacyTopLevelToolSnapshotsRetainMetadataAndDoNotRegress() { + val store = ChatTimelineStore() + store.reset("s") + val turn = message("active-t", "assistant", "", "active").copy(turnId = "t") + store.setActiveTurn(turn.copy(tools = listOf( + RemoteToolStatusResponse(id = "tool", name = "Task", status = "running", inputPreview = "Inspect")))) + store.setActiveTurn(turn.copy(tools = listOf( + RemoteToolStatusResponse(id = "tool", status = "completed", resultPreview = "Done")))) + store.setActiveTurn(turn.copy(tools = listOf( + RemoteToolStatusResponse(id = "tool", status = "running")))) + store.setActiveTurn(turn.copy(tools = emptyList())) + val tool = store.snapshot().activeTurn!!.tools!!.single() + assertEquals("Task", tool.name) + assertEquals("Inspect", tool.inputPreview) + assertEquals("completed", tool.status) + assertEquals("Done", tool.resultPreview) + } + + @Test + fun delayedSendAckDoesNotResurrectCompletedReply() { + val store = ChatTimelineStore() + store.reset("s") + store.appendOptimisticMessage(message("local", "user", "hello").copy(turnId = "client")) + store.setPendingActiveTurn("local") + store.mergePersistedMessages(listOf( + message("user-final", "user", "hello").copy(turnId = "host"), + message("answer", "assistant", "Finished", "completed").copy(turnId = "host"), + )) + store.acknowledgeOptimisticTurn("local", "host") + store.setLocalActiveTurn("host") + assertNull(store.snapshot().activeTurn) + assertTrue(store.snapshot().optimisticMessages.isEmpty()) + assertEquals(ChatSyncPhase.IDLE, store.snapshot().syncPhase) + } + + @Test + fun turnAcknowledgementHandlesOldHostAndPollBeforeAckWithoutConsumingAnotherSend() { + val store = ChatTimelineStore() + store.reset("s") + store.appendOptimisticMessage(message("local-1", "user", "same").copy(turnId = "client-1")) + store.appendOptimisticMessage(message("local-2", "user", "same").copy(turnId = "client-2")) + store.mergePersistedMessages(listOf(message("server", "user", "same").copy(turnId = "host-1"))) + assertEquals(2, store.snapshot().optimisticMessages.size) + store.acknowledgeOptimisticTurn("local-1", "host-1") + assertEquals(listOf("local-2"), store.snapshot().optimisticMessages.map { it.id }) + store.acknowledgeOptimisticTurn("local-2", "host-2") + store.mergePersistedMessages(listOf(message("server-2", "user", "same").copy(turnId = "host-2"))) + assertTrue(store.snapshot().optimisticMessages.isEmpty()) + } + + @Test + fun partialToolCompletionRetainsInvocationAndOutput() { + val store = ChatTimelineStore() + store.reset("s") + val running = RemoteToolStatusResponse(id = "tool", name = "Task", status = "running", + inputPreview = "Inspect files", stdout = "progress") + store.setActiveTurn(message("active-t", "assistant", "", "active").copy( + turnId = "t", items = listOf(ChatMessageItemResponse(type = "tool", tool = running)))) + store.setActiveTurn(message("active-t", "assistant", "", "active").copy( + turnId = "t", items = listOf(ChatMessageItemResponse(type = "tool", + tool = RemoteToolStatusResponse(id = "tool", status = "completed", resultPreview = "done"))))) + val tool = store.snapshot().activeTurn!!.items!!.single().tool!! + assertEquals("completed", tool.status) + assertEquals("Task", tool.name) + assertEquals("Inspect files", tool.inputPreview) + assertEquals("progress", tool.stdout) + assertEquals("done", tool.resultPreview) + } + @Test fun ownsOptimisticMergeAndActiveTurnCleanupRules() { val store = ChatTimelineStore() 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 6bdbc2faac..b0e653b1de 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 @@ -165,6 +165,8 @@ public data class ConversationRow public constructor( public val showRetry: Boolean, /** A user-visible assistant failure returned by the desktop. */ public val error: String?, + /** This is the current turn, including its finalizing snapshot before persistence. */ + public val live: Boolean, ) /** @@ -205,6 +207,7 @@ public fun ChatTimelineState.conversationRows(): List = images = message?.images.orEmpty().map { ConversationImage(it.name, it.dataUrl) }, tools = message?.let(::toolCards).orEmpty(), blocks = message?.let { messageBlocks(it, item.isStreaming) }.orEmpty(), + live = item.type == ChatTimelineItemType.ASSISTANT_LIVE_TURN, streaming = item.isStreaming, typing = message?.let { isTyping(it, item.isStreaming) } == true, pending = item.type == ChatTimelineItemType.OPTIMISTIC_USER_MESSAGE, diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentation.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentation.kt index cfc85b1cc1..2912547719 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentation.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentation.kt @@ -3,7 +3,6 @@ package com.openbitfun.mobile.core.feature.session import com.openbitfun.mobile.core.domain.ChatMessage import com.openbitfun.mobile.core.domain.ToolInputPolicy import com.openbitfun.mobile.core.domain.ToolNamePolicy -import com.openbitfun.mobile.core.domain.ToolStatusPolicy import com.openbitfun.mobile.core.protocol.ChatMessageItemResponse import com.openbitfun.mobile.core.protocol.RemoteToolStatusResponse @@ -53,7 +52,13 @@ public sealed interface MessageBlock { public val running: Boolean, public val text: String, public val children: List, - ) : MessageBlock + /** Preserve host status; running alone loses failure and queued states. */ + public val status: String, + ) : MessageBlock { + // Keep source compatibility without default arguments on the exported feature API. + public constructor(id: String, title: String, running: Boolean, text: String, children: List) : + this(id, title, running, text, children, if (running) "running" else "completed") + } } /** @@ -64,7 +69,7 @@ public sealed interface MessageBlock { * That is the same fork `shouldRenderStructuredItems` makes. */ internal fun messageBlocks(message: ChatMessage, streaming: Boolean): List { - val items = message.items.orEmpty() + val items = scopeSubagentItems(message.items.orEmpty()) if (items.none(::isRenderable)) return emptyList() val blocks = walk(items, message.id, streaming) @@ -74,7 +79,7 @@ internal fun messageBlocks(message: ChatMessage, streaming: Boolean): List): List { + val result = mutableListOf() + val marked = items.any { it.isSubagent == true } + var taskIndex: Int? = null + for (entry in items) { + if (entry.isSubagent != true && entry.tool?.let(ToolNamePolicy::isTask) == true) { + result += entry + taskIndex = result.lastIndex + continue + } + val owner = taskIndex?.let(result::get) + val legacyChild = !marked && owner?.tool?.status.orEmpty().lowercase() in SUBAGENT_RUNNING && + (isThinking(entry) || isText(entry) || entry.tool != null) + if (owner != null && (entry.isSubagent == true || legacyChild)) { + // The marker identifies ownership, not a nested Task card. Keep the + // original kind/tool so child reasoning and tools render as such. + result[taskIndex!!] = owner.copy(subItems = owner.subItems.orEmpty() + entry.copy(isSubagent = false)) + } else { + // A partial snapshot may omit the owning Task. Retain a collapsed + // branch with its content instead of exposing it as parent output. + result += if (entry.isSubagent == true && entry.type.orEmpty().lowercase() !in SUBAGENT_TYPES && + entry.tool?.let(ToolNamePolicy::isTask) != true && entry.subItems.isNullOrEmpty()) { + entry.copy(subItems = listOf(entry.copy(isSubagent = false))) + } else entry + } + } + return result +} + private fun walk(items: List, path: String, streaming: Boolean): List { val blocks = mutableListOf() val toolRun = mutableListOf() @@ -109,12 +144,16 @@ private fun walk(items: List, path: String, streaming: // and is the subagent, rather than something the subagent did. if (isSubagent(entry)) { flushTools() + val status = entry.tool?.status?.takeIf(String::isNotBlank)?.lowercase() + ?: if (live && !entry.subItems.isNullOrEmpty()) "running" else "completed" + val running = status in SUBAGENT_RUNNING blocks += MessageBlock.Subagent( id = "$path-$index-subagent", title = subagentTitle(entry), - running = entry.tool?.let(ToolStatusPolicy::isRunning) == true, - text = entry.content.orEmpty().trim(), - children = walk(entry.subItems.orEmpty(), "$path-$index", streaming && live), + running = running, + text = subagentBody(entry), + children = walk(entry.subItems.orEmpty().map { it.copy(isSubagent = false) }, "$path-$index", running), + status = status, ) return@forEachIndexed } @@ -146,26 +185,6 @@ private fun walk(items: List, path: String, streaming: return blocks } -/** - * While the turn is live, the newest reasoning goes last and the older rounds - * of it go away. - * - * Reasoning that is still being written is the only part of a live turn worth - * following, and leaving it where it was produced pushes it off screen behind - * the output it came before. - */ -private fun pinLiveThinking( - blocks: List, - message: ChatMessage, - streaming: Boolean, -): List { - if (!streaming) return blocks - val live = blocks.filterIsInstance().lastOrNull() - ?: return blocks - val rest = blocks.filter { it !is MessageBlock.Thinking } - return rest + live.copy(id = "${message.id}-live-thinking", streaming = true) -} - /** * The tools the relay reported flat that no item already accounts for. * @@ -220,18 +239,40 @@ private fun isSubagent(entry: ChatMessageItemResponse): Boolean { } /** - * What to call the subagent: what it said, if that is short enough to be a - * heading rather than a paragraph, else the task it was handed. + * Task descriptions take priority over result content. Legacy branches without + * a Task use a short plain label, matching Harmony's subagent title policy. */ private fun subagentTitle(entry: ChatMessageItemResponse): String { - val content = entry.content.orEmpty().trim() + val tool = entry.tool + val task = tool?.let(ToolNamePolicy::isTask) == true + if (task) { + plainSubagentLabel(ToolInputPolicy.taskTitle(tool)).takeIf(String::isNotEmpty)?.let { return it } + } + val content = plainSubagentLabel(entry.content.orEmpty()) if (content.isNotEmpty() && content.length <= TITLE_LIMIT) return content - val tool = entry.tool ?: return "" - // `summary` on a `Task` reads its `description`, which is the subagent's - // brief — the closest thing to a name it was given. - return ToolInputPolicy.summary(tool).ifEmpty { tool.name.orEmpty() } + return if (tool != null && !task) plainSubagentLabel(tool.name.orEmpty()) else "" +} + +private fun subagentBody(entry: ChatMessageItemResponse): String { + if (entry.tool?.let(ToolNamePolicy::isTask) == true) return "" + val content = plainSubagentLabel(entry.content.orEmpty()) + return content.takeUnless { it == subagentTitle(entry) }.orEmpty() } +private fun plainSubagentLabel(raw: String): String { + var text = raw.trim() + for (wrapper in listOf("**", "__", "`", "*", "_")) { + if (text.length > wrapper.length * 2 && text.startsWith(wrapper) && text.endsWith(wrapper)) { + text = text.substring(wrapper.length, text.length - wrapper.length).trim() + break + } + } + while (text.startsWith("#")) text = text.drop(1).trim() + return text +} + +private val SUBAGENT_RUNNING = setOf("running", "active", "preparing", "pending", "queued") + private const val TITLE_LIMIT = 80 private val TEXT_TYPES = setOf("text", "message", "") private val SUBAGENT_TYPES = setOf("subagent", "agent") 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 324d0f5aa8..ac94bf95a3 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 @@ -1191,7 +1191,7 @@ public class RemoteSessionStore internal constructor( } catch (error: Throwable) { if (isCurrentWork(operationToken)) { setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) - handleFailure(error, current) + handleFailure(error, (_state.value as? RemoteSessionUiState.Ready) ?: current) } } } @@ -1229,7 +1229,7 @@ public class RemoteSessionStore internal constructor( } catch (error: Throwable) { if (isCurrentWork(operationToken)) { setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) - handleFailure(error, current) + handleFailure(error, (_state.value as? RemoteSessionUiState.Ready) ?: current) } } } @@ -1326,14 +1326,21 @@ public class RemoteSessionStore internal constructor( RemoteCommand(cmd = "get_session_messages", sessionId = sessionId, limit = 100), ) if (timelineStore.snapshot().sessionId != sessionId) return@launch - timelineStore.setPersistedMessages(response.messages.map(RemoteResponseMapper::chatMessage)) + val before = timelineStore.snapshot() + val messages = response.messages.map(RemoteResponseMapper::chatMessage) + // A bounded tail is not an authoritative replacement for history + // the user already paged in. Full snapshots may still remove rows. + if (response.hasMore) timelineStore.mergePersistedMessages(messages) + else timelineStore.setPersistedMessages(messages) // Back to version zero, matching `onMessageCountKnown(0, …)`: the // transcript just came from the source of truth, so the next poll // should describe everything it knows rather than the delta since // a version whose messages have already been replaced. val cursor = ChatSessionCursor( pollVersion = 0, - knownMessageCount = response.messages.size, + knownMessageCount = if (response.hasMore) { + maxOf(before.cursor.knownMessageCount, response.messages.size) + } else response.messages.size, knownModelCatalogVersion = timelineStore.snapshot().cursor.knownModelCatalogVersion, ) timelineStore.setCursor(cursor) @@ -1343,7 +1350,7 @@ public class RemoteSessionStore internal constructor( if (current is RemoteSessionUiState.Ready) { _state.value = current.copy( timeline = timelineStore.snapshot(), - hasMoreMessages = response.hasMore, + hasMoreMessages = response.hasMore && current.hasMoreMessages, ) } } catch (cancelled: CancellationException) { @@ -1379,13 +1386,14 @@ public class RemoteSessionStore internal constructor( metadata = null, ) } + val clientTurnId = "mobile-${Clock.System.now().toEpochMilliseconds()}-${kotlin.random.Random.nextLong().toULong().toString(16)}" val local = ChatMessage( - id = "msg-${Clock.System.now().toEpochMilliseconds()}", + id = clientTurnId, role = "user", text = content, status = "sent", renderVersion = null, - turnId = null, + turnId = clientTurnId, detail = null, timestamp = null, thinking = null, @@ -1395,7 +1403,9 @@ public class RemoteSessionStore internal constructor( error = null, ) timelineStore.appendOptimisticMessage(local) - setBusy(current, true) + val pendingActiveId = timelineStore.setPendingActiveTurn(local.id) + controller.nudge() + _state.value = current.copy(busy = true, timeline = timelineStore.snapshot()) val operationToken = beginWork() work = scope.launch { try { @@ -1404,7 +1414,7 @@ public class RemoteSessionStore internal constructor( val response = transport.send( RemoteCommand( cmd = if (plan != null) "build_plan" else if (steering) "steer_turn" else "send_message", - turnId = activeTurnId.takeIf { steering }, + turnId = if (steering) activeTurnId else if (plan == null && activeTurnId == null) clientTurnId else null, displayContent = content.takeIf { steering }, planFilePath = plan?.path, planName = plan?.name, @@ -1415,7 +1425,10 @@ public class RemoteSessionStore internal constructor( ), ) if (!isCurrentWork(operationToken)) return@launch - if (!steering && current.timeline?.activeTurn == null) response.turnId?.let(timelineStore::setLocalActiveTurn) + response.turnId?.takeIf(String::isNotBlank)?.let { turnId -> + timelineStore.acknowledgeOptimisticTurn(local.id, turnId) + if (!steering && current.timeline?.activeTurn == null) timelineStore.setLocalActiveTurn(turnId) + } ?: timelineStore.clearPendingActiveTurn(pendingActiveId) controller.nudge() val ready = ((_state.value as? RemoteSessionUiState.Ready) ?: current) if (ready.selectedSessionId == sessionId) { @@ -1425,6 +1438,7 @@ public class RemoteSessionStore internal constructor( val draftUnchanged = plan == null && ready.draft == current.draft && ready.draft.trim() == content.trim() if (draftUnchanged) deletePersistedDraft(sessionId) _state.value = ready.copy( + timeline = timelineStore.snapshot(), draft = if (draftUnchanged) "" else ready.draft, lastSentMessage = SentChatMessage( local.id, sessionId, content, intent.images.orEmpty().map { it.id }, @@ -1433,11 +1447,23 @@ public class RemoteSessionStore internal constructor( } setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) } catch (cancelled: CancellationException) { + // A list refresh or another command may supersede this RPC. + // Do not leave an unowned waiting placeholder, or touch a newly opened session. + if (timelineStore.snapshot().sessionId == sessionId) { + timelineStore.markOptimisticMessageFailed(local.id) + timelineStore.clearPendingActiveTurn(pendingActiveId) + val ready = _state.value as? RemoteSessionUiState.Ready + if (ready?.selectedSessionId == sessionId) { + _state.value = ready.copy(timeline = timelineStore.snapshot()) + } + } throw cancelled } catch (error: Throwable) { if (isCurrentWork(operationToken)) { timelineStore.markOptimisticMessageFailed(local.id) - handleFailure(error, (_state.value as? RemoteSessionUiState.Ready) ?: current) + timelineStore.clearPendingActiveTurn(pendingActiveId) + handleFailure(error, ((_state.value as? RemoteSessionUiState.Ready) ?: current) + .copy(timeline = timelineStore.snapshot())) } } } @@ -1619,7 +1645,8 @@ public class RemoteSessionStore internal constructor( private fun selectModel(intent: RemoteSessionIntent.SelectModel) { val current = _state.value as? RemoteSessionUiState.Ready ?: return - if (intent.modelId.trim().isEmpty()) return + if (intent.modelId.trim().isEmpty() || current.selectedSessionId != intent.sessionId || + timelineStore.snapshot().sessionId != intent.sessionId) return setBusy(current, true) val operationToken = beginWork() work = scope.launch { @@ -1629,13 +1656,14 @@ public class RemoteSessionStore internal constructor( ) if (!isCurrentWork(operationToken)) return@launch timelineStore.setSelectedModelId(response.modelId ?: intent.modelId) - setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) + val ready = (_state.value as? RemoteSessionUiState.Ready) ?: current + _state.value = ready.copy(busy = false, timeline = timelineStore.snapshot()) } catch (cancelled: CancellationException) { throw cancelled } catch (error: Throwable) { if (isCurrentWork(operationToken)) { setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) - handleFailure(error, current) + handleFailure(error, (_state.value as? RemoteSessionUiState.Ready) ?: current) } } } @@ -1656,7 +1684,7 @@ public class RemoteSessionStore internal constructor( } catch (error: Throwable) { if (isCurrentWork(operationToken)) { setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) - handleFailure(error, current) + handleFailure(error, (_state.value as? RemoteSessionUiState.Ready) ?: current) } } } 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 c79c176114..5c04f359dc 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 @@ -54,6 +54,8 @@ public class RemoteWorkspaceStore internal constructor( /** Changes only when this target store is stopped; useful to cancel observers. */ public val stopVersion: StateFlow = _stopVersion.asStateFlow() private var work: Job? = null + private var previewWork: Job? = null + private var downloadWork: Job? = null private var loadGeneration: Long = 0 private var targetEpoch: Int = 0 private var previewGeneration: Long = 0 @@ -66,7 +68,20 @@ public class RemoteWorkspaceStore internal constructor( return PreviewRequestIdentity(requestId, deviceKey, target.sessionId, target.remotePath) } + private fun cancelDownload() { + downloadWork?.cancel() + downloadWork = null + updateReady { ready -> + val loading = ready.download as? RemoteFileDownloadUiState.Loading + if (loading == null) ready else ready.copy(download = RemoteFileDownloadUiState.Failed( + loading.target, FilePreviewFailureKind.UNAVAILABLE, true, + )) + } + } + private fun invalidatePreview() { + previewWork?.cancel() + previewWork = null previewGeneration += 1 activePreviewRequestId = null } @@ -92,6 +107,7 @@ public class RemoteWorkspaceStore internal constructor( _stopVersion.value += 1 loadGeneration += 1 invalidatePreview() + cancelDownload() work?.cancel() work = null } @@ -143,6 +159,7 @@ public class RemoteWorkspaceStore internal constructor( private fun load() { val generation = ++loadGeneration invalidatePreview() + cancelDownload() work?.cancel() if (_state.value !is RemoteWorkspaceUiState.Ready && persistenceEnabled) { val cached = try { @@ -244,8 +261,9 @@ public class RemoteWorkspaceStore internal constructor( val current = _state.value as? RemoteWorkspaceUiState.Ready ?: return val generation = ++loadGeneration invalidatePreview() + cancelDownload() work?.cancel() - _state.value = current.copy(busy = true) + _state.value = ((_state.value as? RemoteWorkspaceUiState.Ready) ?: current).copy(busy = true) work = scope.launch { try { if (assistant) { @@ -268,9 +286,9 @@ public class RemoteWorkspaceStore internal constructor( val current = _state.value as? RemoteWorkspaceUiState.Ready ?: return val identity = nextPreviewIdentity(target, requestedId) val generation = previewGeneration - work?.cancel() + previewWork?.cancel() _state.value = current.copy(preview = RemoteFilePreviewUiState.Loading(target, identity)) - work = scope.launch { + previewWork = scope.launch { try { val info = transport.send( RemoteCommand(cmd = "get_file_info", path = target.remotePath, sessionId = target.sessionId.ifEmpty { null }), @@ -335,6 +353,7 @@ public class RemoteWorkspaceStore internal constructor( true, "", 0, + nextPreviewIdentity(placeholder, intent.requestId), ), ) } @@ -386,13 +405,12 @@ public class RemoteWorkspaceStore internal constructor( if (current.busy || current.download is RemoteFileDownloadUiState.Loading || current.download is RemoteFileDownloadUiState.AwaitingSave ) return - work?.cancel() + downloadWork?.cancel() _state.value = current.copy(download = RemoteFileDownloadUiState.Loading(target, 0, 0)) - val downloadEpoch = targetEpoch val downloadGeneration = loadGeneration val downloadStopVersion = _stopVersion.value - fun downloadIsCurrent(): Boolean = targetEpoch == downloadEpoch && loadGeneration == downloadGeneration && _stopVersion.value == downloadStopVersion - work = scope.launch { + fun downloadIsCurrent(): Boolean = loadGeneration == downloadGeneration && _stopVersion.value == downloadStopVersion + downloadWork = scope.launch { try { val info = transport.send( RemoteCommand( diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentationTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentationTest.kt index b26bcfa676..6d957658b7 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentationTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentationTest.kt @@ -8,6 +8,85 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue class MessageBlockPresentationTest { + @Test + fun markedFlatChildrenBelongToTheirTaskNotTheMainTranscript() { + val task1 = item(tool = tool("task1", name = "Task", status = "running")) + val task2 = item(tool = tool("task2", name = "Task", status = "completed")) + val thought = item(type = "thinking", content = "Private reasoning").copy(isSubagent = true) + val childTool = item(tool = tool("read", status = "completed")).copy(isSubagent = true) + val childText = item(type = "text", content = "Subtask result").copy(isSubagent = true) + val blocks = messageBlocks(message(items = listOf(task1, thought, childTool, task2, childText, + item(type = "text", content = "Parent answer")), tools = listOf(childTool.tool!!)), true) + assertEquals(listOf("subagent", "subagent", "Parent answer"), blocks.map(::describe)) + assertEquals(listOf("Private reasoning", "tools"), (blocks[0] as MessageBlock.Subagent).children.map(::describe)) + assertEquals(listOf("Subtask result"), (blocks[1] as MessageBlock.Subagent).children.map(::describe)) + } + + @Test + fun legacyUnmarkedChildrenUseOnlyARunningTaskScope() { + for (status in listOf("running", "queued", "completed")) { + val blocks = messageBlocks(message(items = listOf(item(tool = tool("task", name = "Task", status = status)), + item(type = "thinking", content = "Legacy thinking"), item(tool = tool("read", status = "completed")))), true) + val task = blocks.first() as MessageBlock.Subagent + assertEquals(if (status == "completed") 0 else 2, task.children.size) + } + } + + @Test + fun alreadyNestedMarkersRenderAsChildrenAndDoNotDuplicate() { + val nested = item(type = "thinking", content = "Nested").copy(isSubagent = true) + val task = messageBlocks(message(items = listOf(item(tool = tool("task", name = "Task", status = "running"), + subItems = listOf(nested)))), true).single() as MessageBlock.Subagent + assertTrue(task.children.single() is MessageBlock.Thinking) + val orphan = messageBlocks(message(items = listOf(nested)), true).single() as MessageBlock.Subagent + assertEquals("Nested", (orphan.children.single() as MessageBlock.Thinking).text, + "Partial legacy snapshots retain orphan content inside a collapsed branch") + } + + @Test + fun subagentStatusPreservesHostVocabularyAndOwnActiveScope() { + for (status in listOf("running", "active", "preparing", "pending", "queued", "failed", "error", "timeout", "cancelled", "canceled", "rejected", "completed")) { + val blocks = messageBlocks(message(items = listOf( + item(type = "subagent", tool = tool("task", name = "Task", status = status)), + item(type = "thinking", content = "child").copy(isSubagent = true), + item(type = "text", content = "Parent continues"))), true) + val child = blocks.first() as MessageBlock.Subagent + val running = status in listOf("running", "active", "preparing", "pending", "queued") + assertEquals(status, child.status) + assertEquals(running, child.running) + assertEquals(running, (child.children.single() as MessageBlock.Thinking).streaming) + } + } + + @Test + fun legacySubagentWithoutToolInheritsActiveScope() { + val payload = message(items = listOf(item(type = "subagent", content = "Legacy", subItems = listOf(item(type = "thinking", content = "Working"))))) + assertTrue((messageBlocks(payload, true).single() as MessageBlock.Subagent).running) + assertEquals("completed", (messageBlocks(payload, false).single() as MessageBlock.Subagent).status) + val oldConstructor = MessageBlock.Subagent("id", "title", false, "", emptyList()) + assertEquals("completed", oldConstructor.status) + } + + @Test + fun legacyTaskTitleAliasesRemainReadable() { + for (key in listOf("description", "task", "title", "prompt", "message", "task_name", "taskName", "name", "content")) { + val child = messageBlocks(message(items = listOf(item(type = "tool", content = "Result", + tool = tool("task", name = "Task", status = "completed", inputPreview = """{"$key":"Inspect"}""")))), false).single() as MessageBlock.Subagent + assertEquals("Inspect", child.title) + } + } + + @Test + fun taskDescriptionWinsOverResultAndDoesNotBecomeAnotherBody() { + val child = messageBlocks(message(items = listOf(item(type = "tool", content = "Finished", + tool = tool("task", name = "Task", status = "completed", inputPreview = """{"description":"**Audit security**"}""")))), false).single() as MessageBlock.Subagent + assertEquals("Audit security", child.title) + assertEquals("", child.text) + val legacy = messageBlocks(message(items = listOf(item(type = "subagent", content = "## Legacy title"))), false).single() as MessageBlock.Subagent + assertEquals("Legacy title", legacy.title) + assertEquals("", legacy.text) + } + @Test fun aPlainAnswerHasNoBlocksSoTheAppKeepsItsFlatPath() { val blocks = messageBlocks(message(text = "Done."), false) @@ -101,7 +180,7 @@ class MessageBlockPresentationTest { } @Test - fun liveReasoningMovesBelowTheOutputItCameBefore() { + fun liveReasoningKeepsChronologicalOrderAndStableBlockIds() { val items = listOf( item(type = "thinking", content = "which file first"), item(type = "text", content = "Starting with the manifest."), @@ -114,13 +193,42 @@ class MessageBlockPresentationTest { settled.map(::describe), ) - // Streaming: the reasoning being written is the only part worth - // following, so it goes last and the round before it goes away. val live = messageBlocks(message(items = items), true) - assertEquals(listOf("Starting with the manifest.", "now the gradle file"), live.map(::describe)) + assertEquals(settled.map(::describe), live.map(::describe)) + assertEquals(settled.map { it.id }, live.map { it.id }) + assertTrue(!(live.first() as MessageBlock.Thinking).streaming) assertTrue((live.last() as MessageBlock.Thinking).streaming) } + @Test + fun answerAfterThinkingDoesNotMoveAboveItWhileStreaming() { + val thought = item(type = "thinking", content = "Consider the request.") + val before = messageBlocks(message(items = listOf(thought)), true) + val withAnswer = message(items = listOf(thought, item(type = "text", content = "Answer"))) + val live = messageBlocks(withAnswer, true) + val done = messageBlocks(withAnswer, false) + assertEquals(listOf("Consider the request.", "Answer"), live.map(::describe)) + assertEquals(before.first().id, live.first().id) + assertEquals(live.map { it.id }, done.map { it.id }) + assertTrue(!(live.first() as MessageBlock.Thinking).streaming) + assertTrue((live.last() as MessageBlock.Text).streaming) + } + + @Test + fun thinkingStaysBeforeToolsAndAnswerAcrossCompletion() { + val source = message(items = listOf( + item(type = "thinking", content = "Inspect first"), + item(tool = tool(id = "t-1", status = "completed")), + item(type = "text", content = "Result"), + )) + val live = messageBlocks(source, true) + val done = messageBlocks(source, false) + assertTrue(live[0] is MessageBlock.Thinking) + assertTrue(live[1] is MessageBlock.Tools) + assertTrue(live[2] is MessageBlock.Text) + assertEquals(live.map { it.id }, done.map { it.id }) + } + @Test fun aToolReportedBothFlatAndInlineIsNotDrawnTwice() { val blocks = messageBlocks( 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 c2db38b3fb..3de9b67098 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 @@ -251,6 +251,85 @@ class RemoteSessionStoreTest { store.dispatch(RemoteSessionIntent.Stop) } + @Test + fun sendingPublishesPendingAndFailedMessagesWithoutPolling() = runTest { + val transport = FakeSessionTransport() + val store = RemoteSessionStore.create(this, transport) + store.dispatch(RemoteSessionIntent.Open("s-code")) + runCurrent() + val gate = CompletableDeferred() + transport.commandGates["send_message"] = gate + transport.sendMessageFailure = RelayFailure.NetworkUnreachable + store.dispatch(RemoteSessionIntent.SendMessage("s-code", "hello")) + runCurrent() + val pending = assertIs(store.state.value) + assertEquals("hello", pending.timeline?.optimisticMessages?.single()?.text) + assertTrue(pending.busy) + store.dispatch(RemoteSessionIntent.UpdateDraft("new typing")) + gate.complete(Unit) + runCurrent() + val failed = assertIs(store.state.value) + assertEquals("failed", failed.timeline?.optimisticMessages?.single()?.status) + assertEquals("new typing", failed.draft) + assertFalse(failed.busy) + store.stop() + } + + @Test + fun staleModelSelectionCannotMutateTheNewlyOpenedSession() = runTest { + val transport = FakeSessionTransport() + val store = RemoteSessionStore.create(this, transport) + store.dispatch(RemoteSessionIntent.Open("s-code")) + runCurrent() + transport.commands.clear() + store.dispatch(RemoteSessionIntent.SelectModel("old-session", "model-primary")) + runCurrent() + assertTrue(transport.commands.none { it.cmd == "set_session_model" }) + assertFalse(assertIs(store.state.value).busy) + store.stop() + } + + @Test + fun modelFailurePreservesTypingThatArrivedWhileRequestWasPending() = runTest { + val transport = FakeSessionTransport() + val store = RemoteSessionStore.create(this, transport) + store.dispatch(RemoteSessionIntent.Open("s-code")) + runCurrent() + val gate = CompletableDeferred() + transport.commandGates["set_session_model"] = gate + transport.modelSelectionFailure = RelayFailure.NetworkUnreachable + store.dispatch(RemoteSessionIntent.SelectModel("s-code", "model-primary")) + runCurrent() + store.dispatch(RemoteSessionIntent.UpdateDraft("new typing")) + gate.complete(Unit) + runCurrent() + assertEquals("new typing", assertIs(store.state.value).draft) + store.stop() + } + + @Test + fun selectingModelPublishesConfirmedSelectionWithoutAnotherPoll() = runTest { + val transport = FakeSessionTransport() + val store = RemoteSessionStore.create(this, transport) + store.dispatch(RemoteSessionIntent.Load) + advanceUntilIdle() + store.dispatch(RemoteSessionIntent.Open("s-code")) + runCurrent() + store.dispatch(RemoteSessionIntent.UpdateDraft("keep-draft")) + transport.commands.clear() + store.dispatch(RemoteSessionIntent.SelectModel("s-code", "requested-model")) + runCurrent() + val ready = assertIs(store.state.value) + // The host's confirmed model wins even if it differs from the request. + assertEquals("model-primary", ready.timeline?.selectedModelId) + assertEquals("model-primary", ready.createModelOptions("Model").single { it.selected }.id) + assertEquals("keep-draft", ready.draft) + assertEquals("s-code", ready.selectedSessionId) + assertFalse(ready.busy) + assertEquals(listOf("set_session_model"), transport.commands.map { it.cmd }) + store.dispatch(RemoteSessionIntent.Stop) + } + @Test fun modelCatalogContractIsTheStaticSupportedFact() { assertEquals("get_model_catalog", ModelCatalogContract.commandName) @@ -1122,6 +1201,47 @@ class RemoteSessionStoreTest { store.stop() } + @Test + fun refreshCancellingSendDoesNotLeavePendingTurnForever() = runTest { + val transport = FakeSessionTransport() + val store = RemoteSessionStore.create(this, transport) + store.dispatch(RemoteSessionIntent.Open("s-code")) + runCurrent() + transport.commandGates["send_message"] = CompletableDeferred() + store.dispatch(RemoteSessionIntent.SendMessage("s-code", "waiting")) + runCurrent() + store.dispatch(RemoteSessionIntent.Refresh) + runCurrent() + val ready = assertIs(store.state.value) + assertFalse(ready.timeline?.activeTurn?.id?.startsWith("active-pending-") == true) + assertFalse(ready.busy) + store.stop() + } + + @Test + fun sendPublishesPendingTurnBeforeAckAndClearsItOnFailure() = runTest { + val transport = FakeSessionTransport() + val store = RemoteSessionStore.create(this, transport) + store.dispatch(RemoteSessionIntent.Open("s-code")) + runCurrent() + val gate = CompletableDeferred() + transport.commandGates["send_message"] = gate + transport.sendMessageFailure = RelayFailure.NetworkUnreachable + store.dispatch(RemoteSessionIntent.SendMessage("s-code", "hello")) + runCurrent() + val pending = assertIs(store.state.value).timeline!! + assertTrue(pending.activeTurn!!.id.startsWith("active-pending-")) + val command = transport.commands.last { it.cmd == "send_message" } + assertEquals(pending.optimisticMessages.single().turnId, command.turnId) + assertTrue(!command.turnId.isNullOrBlank()) + gate.complete(Unit) + runCurrent() + val failed = assertIs(store.state.value).timeline!! + assertNull(failed.activeTurn) + assertEquals("failed", failed.optimisticMessages.single().status) + store.stop() + } + @Test fun imageSendFailureDoesNotConsumeAttachmentsAndAckKeepsNewTyping() = runTest { val transport = FakeSessionTransport() @@ -1370,6 +1490,35 @@ class RemoteSessionStoreTest { assertEquals(0, transport.commands.last { it.cmd == "poll_session" }.sinceVersion) } + @Test + fun turnEndTailRefreshPreservesLoadedOlderMessages() = runTest { + val transport = FakeSessionTransport() + transport.messages = """[{"id":"m-new","role":"user","content":"question"}]""" + transport.olderMessages = """[{"id":"m-old","role":"user","content":"older"}]""" + transport.polls = listOf( + """{"resp":"ok","version":1,"changed":true,"session_state":"running", + "active_turn":{"turn_id":"t-1","status":"active","text":"All "}}""", + """{"resp":"ok","version":2,"changed":true,"session_state":"idle", + "active_turn":{"turn_id":"t-1","status":"completed","text":"All done"}}""", + """{"resp":"ok","version":2,"changed":false,"session_state":"idle"}""", + ) + val store = RemoteSessionStore.create(this, transport) + store.dispatch(RemoteSessionIntent.Open("s-code")) + runCurrent() + store.dispatch(RemoteSessionIntent.LoadOlderMessages) + runCurrent() + transport.messages = """[{"id":"m-new","role":"user","content":"question"}, + {"id":"t-1_assistant","turn_id":"t-1","role":"assistant","content":"All done","status":"completed"}]""" + advanceTimeBy(1_000) + runCurrent() + val ready = assertIs(store.state.value) + assertEquals(listOf("m-old", "m-new", "t-1_assistant"), + ready.timeline?.persistedMessages?.map { it.id }) + assertFalse(ready.hasMoreMessages) + assertNull(ready.timeline?.activeTurn) + store.dispatch(RemoteSessionIntent.Stop) + } + @Test fun aRefusedPermissionChangeLeavesTheSessionStanding() = runTest { val transport = FakeSessionTransport() @@ -1452,6 +1601,7 @@ private class FakeSessionTransport : RemoteCommandTransport { /** When set, `send_message` fails below the desktop while the draft is kept. */ var sendMessageFailure: RelayFailure? = null + var modelSelectionFailure: RelayFailure? = null /** When set, `create_session` fails below the desktop. */ var createFailure: RelayFailure? = null @@ -1485,6 +1635,9 @@ private class FakeSessionTransport : RemoteCommandTransport { null } commandGates[command.cmd]?.await() + if (command.cmd == "set_session_model") { + modelSelectionFailure?.let { throw RelayTransportException(it) } + } if (nonCancellableCommands.remove(command.cmd)) { suspendCoroutine { continuation -> lateCommandContinuations[command.cmd] = continuation } } diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStoreTest.kt index 2e0f3948cf..930512301b 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStoreTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStoreTest.kt @@ -123,6 +123,91 @@ class RemoteWorkspaceStoreTest { assertFalse(ready.loadFailure) } + @Test + fun invalidPreviewRetainsRequestIdentityAndReplacesPreviousRequest() = runTest { + val store = RemoteWorkspaceStore.create(this, FakeWorkspaceTransport(), StandardTestDispatcher(testScheduler), "device-a") + store.dispatch(RemoteWorkspaceIntent.Load) + advanceUntilIdle() + store.dispatch(RemoteWorkspaceIntent.OpenFile("https://example.com/file", "file", "s1", "invalid-preview")) + runCurrent() + val failed = assertIs(assertIs(store.state.value).preview) + assertEquals("invalid-preview", failed.identity.requestId) + assertEquals("device-a", failed.identity.deviceKey) + assertEquals("s1", failed.identity.sessionId) + store.stop() + } + + @Test + fun previewDoesNotCancelWorkspaceRefresh() = runTest { + val transport = FakeWorkspaceTransport() + val store = RemoteWorkspaceStore.create(this, transport) + store.dispatch(RemoteWorkspaceIntent.Load) + advanceUntilIdle() + val gate = CompletableDeferred() + transport.commandGates["get_workspace_info"] = gate + store.dispatch(RemoteWorkspaceIntent.Load) + runCurrent() + store.dispatch(RemoteWorkspaceIntent.OpenFile("src/main.rs", "main.rs", "s1", "preview")) + runCurrent() + gate.complete(Unit) + advanceUntilIdle() + assertFalse(assertIs(store.state.value).busy) + store.stop() + } + + @Test + fun workspaceRefreshDoesNotLeaveCancelledDownloadPermanentlyLoading() = runTest { + val transport = FakeWorkspaceTransport(downloadChunks = true) + val store = RemoteWorkspaceStore.create(this, transport, StandardTestDispatcher(testScheduler), "device-a") + store.dispatch(RemoteWorkspaceIntent.Load) + advanceUntilIdle() + val gate = CompletableDeferred() + transport.commandGates["get_file_info"] = gate + store.dispatch(RemoteWorkspaceIntent.DownloadFile("src/main.rs", "main.rs", "s1")) + runCurrent() + store.dispatch(RemoteWorkspaceIntent.Load) + runCurrent() + assertFalse(assertIs(store.state.value).download is RemoteFileDownloadUiState.Loading) + gate.complete(Unit) + store.dispatch(RemoteWorkspaceIntent.DownloadFile("src/main.rs", "main.rs", "s1")) + advanceUntilIdle() + assertIs(assertIs(store.state.value).download) + store.stop() + } + + @Test + fun workspaceSelectionRetainsDownloadCancellationInsteadOfRestoringLoading() = runTest { + val transport = FakeWorkspaceTransport(downloadChunks = true) + val store = RemoteWorkspaceStore.create(this, transport, StandardTestDispatcher(testScheduler), "device-a") + store.dispatch(RemoteWorkspaceIntent.Load) + advanceUntilIdle() + transport.commandGates["get_file_info"] = CompletableDeferred() + store.dispatch(RemoteWorkspaceIntent.DownloadFile("src/main.rs", "main.rs", "s1")) + runCurrent() + store.dispatch(RemoteWorkspaceIntent.SelectWorkspace("/repo")) + runCurrent() + assertIs(assertIs(store.state.value).download) + store.stop() + } + + @Test + fun previewDoesNotCancelAnInFlightDownload() = runTest { + val transport = FakeWorkspaceTransport(downloadChunks = true) + val store = RemoteWorkspaceStore.create(this, transport, StandardTestDispatcher(testScheduler), "device-a") + store.dispatch(RemoteWorkspaceIntent.Load) + advanceUntilIdle() + val gate = CompletableDeferred() + transport.commandGates["get_file_info"] = gate + store.dispatch(RemoteWorkspaceIntent.DownloadFile("src/main.rs", "main.rs", "s1")) + runCurrent() + store.dispatch(RemoteWorkspaceIntent.OpenFile("src/main.rs", "main.rs", "s1", "preview")) + runCurrent() + gate.complete(Unit) + advanceUntilIdle() + assertIs(assertIs(store.state.value).download) + store.stop() + } + @Test fun loadsBoundedTextPreviewThroughCommandTransport() = runTest { val transport = FakeWorkspaceTransport() @@ -620,6 +705,7 @@ private class FakeWorkspaceTransport( private val revisionChanges: Boolean = false, ) : RemoteCommandTransport { val commands = mutableListOf() + val commandGates = mutableMapOf>() var fileInfoError: String? = null /** What the desktop calls the file, and the bytes it hands back for it. */ @@ -632,6 +718,7 @@ private class FakeWorkspaceTransport( timeoutMs: Long, ): T { commands += command + commandGates[command.cmd]?.await() val json = when (command.cmd) { "list_recent_workspaces" -> """{"resp":"ok","workspaces":[{"path":"/repo","name":"Repo","last_opened":"2026-08-09"}]}"""