From 3495c4d026d349aa322a137aa06dc19e1df523ec Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Sat, 23 May 2026 14:11:41 +0800 Subject: [PATCH] feat: add chat message list package --- .../project.pbxproj | 351 ++++++++++++ .../xcshareddata/swiftpm/Package.resolved | 60 +++ .../MessageListSimulatorApp.swift | 165 ++++++ .../MessageListSimulatorApp/README.md | 21 + Packages/Package.swift | 17 + .../Sources/MessageList/MessageList.swift | 501 ++++++++++++++++++ .../MessageListPinningController.swift | 67 +++ .../MessageList/MessageListPreview.swift | 209 ++++++++ .../MessageList/MessageListScrollAnchor.swift | 49 ++ .../RxCodeChatKit/ChatTranscriptList.swift | 150 ++++++ .../Sources/RxCodeChatKit/MessageBubble.swift | 103 +++- .../RxCodeChatKit/MessageListView.swift | 206 +++---- .../MessageListPinningControllerTests.swift | 108 ++++ .../MessageListPreviewTests.swift | 107 ++++ .../MessageListScrollAnchorTests.swift | 41 ++ RxCode/App/AppState+CrossProject.swift | 2 +- RxCode/App/AppState+Messaging.swift | 2 +- RxCode/App/AppState+Project.swift | 2 +- RxCode/App/AppState+Stream.swift | 65 ++- RxCode/App/AppState.swift | 2 +- RxCodeMobile/Views/MobileChatView.swift | 361 ++++++------- .../Support/MobileAppRobot.swift | 15 +- 22 files changed, 2284 insertions(+), 320 deletions(-) create mode 100644 Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj/project.pbxproj create mode 100644 Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp/MessageListSimulatorApp.swift create mode 100644 Packages/Examples/MessageListSimulatorApp/README.md create mode 100644 Packages/Sources/MessageList/MessageList.swift create mode 100644 Packages/Sources/MessageList/MessageListPinningController.swift create mode 100644 Packages/Sources/MessageList/MessageListPreview.swift create mode 100644 Packages/Sources/MessageList/MessageListScrollAnchor.swift create mode 100644 Packages/Sources/RxCodeChatKit/ChatTranscriptList.swift create mode 100644 Packages/Tests/MessageListTests/MessageListPinningControllerTests.swift create mode 100644 Packages/Tests/MessageListTests/MessageListPreviewTests.swift create mode 100644 Packages/Tests/MessageListTests/MessageListScrollAnchorTests.swift diff --git a/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj/project.pbxproj b/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj/project.pbxproj new file mode 100644 index 00000000..6c8aa8b0 --- /dev/null +++ b/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj/project.pbxproj @@ -0,0 +1,351 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 111111111111111111111101 /* MessageListSimulatorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 111111111111111111111001 /* MessageListSimulatorApp.swift */; }; + 111111111111111111111102 /* MessageList in Frameworks */ = {isa = PBXBuildFile; productRef = 111111111111111111111301 /* MessageList */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 111111111111111111111001 /* MessageListSimulatorApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageListSimulatorApp.swift; sourceTree = ""; }; + 111111111111111111111002 /* MessageListSimulatorApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MessageListSimulatorApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 111111111111111111111201 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 111111111111111111111102 /* MessageList in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 111111111111111111111401 = { + isa = PBXGroup; + children = ( + 111111111111111111111402 /* MessageListSimulatorApp */, + 111111111111111111111403 /* Products */, + ); + sourceTree = ""; + }; + 111111111111111111111402 /* MessageListSimulatorApp */ = { + isa = PBXGroup; + children = ( + 111111111111111111111001 /* MessageListSimulatorApp.swift */, + ); + path = MessageListSimulatorApp; + sourceTree = ""; + }; + 111111111111111111111403 /* Products */ = { + isa = PBXGroup; + children = ( + 111111111111111111111002 /* MessageListSimulatorApp.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 111111111111111111111501 /* MessageListSimulatorApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 111111111111111111111801 /* Build configuration list for PBXNativeTarget "MessageListSimulatorApp" */; + buildPhases = ( + 111111111111111111111601 /* Sources */, + 111111111111111111111201 /* Frameworks */, + 111111111111111111111602 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MessageListSimulatorApp; + packageProductDependencies = ( + 111111111111111111111301 /* MessageList */, + ); + productName = MessageListSimulatorApp; + productReference = 111111111111111111111002 /* MessageListSimulatorApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 111111111111111111111701 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2630; + LastUpgradeCheck = 2630; + TargetAttributes = { + 111111111111111111111501 = { + CreatedOnToolsVersion = 26.3; + }; + }; + }; + buildConfigurationList = 111111111111111111111802 /* Build configuration list for PBXProject "MessageListSimulatorApp" */; + compatibilityVersion = "Xcode 15.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 111111111111111111111401; + packageReferences = ( + 111111111111111111111302 /* XCLocalSwiftPackageReference "../../" */, + ); + productRefGroup = 111111111111111111111403 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 111111111111111111111501 /* MessageListSimulatorApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 111111111111111111111602 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 111111111111111111111601 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 111111111111111111111101 /* MessageListSimulatorApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 111111111111111111111901 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 111111111111111111111902 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 111111111111111111111903 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rxlab.MessageListSimulatorApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 111111111111111111111904 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rxlab.MessageListSimulatorApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 111111111111111111111801 /* Build configuration list for PBXNativeTarget "MessageListSimulatorApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 111111111111111111111903 /* Debug */, + 111111111111111111111904 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 111111111111111111111802 /* Build configuration list for PBXProject "MessageListSimulatorApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 111111111111111111111901 /* Debug */, + 111111111111111111111902 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 111111111111111111111302 /* XCLocalSwiftPackageReference "../../" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../..; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 111111111111111111111301 /* MessageList */ = { + isa = XCSwiftPackageProductDependency; + package = 111111111111111111111302 /* XCLocalSwiftPackageReference "../../" */; + productName = MessageList; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 111111111111111111111701 /* Project object */; +} diff --git a/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..647395b0 --- /dev/null +++ b/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,60 @@ +{ + "originHash" : "0742dbd4b5422e8d476168a334a6c2788dd1c5ed06c9e5ca65cf7f02bdf63de4", + "pins" : [ + { + "identity" : "networkimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/NetworkImage", + "state" : { + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" + } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark", + "state" : { + "revision" : "924936d0427cb25a61169739a7660230bffa6ea6", + "version" : "0.8.0" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", + "version" : "1.3.2" + } + }, + { + "identity" : "swift-markdown-ui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swift-markdown-ui", + "state" : { + "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", + "version" : "2.4.1" + } + }, + { + "identity" : "swiftui-math", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swiftui-math", + "state" : { + "revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71", + "version" : "0.1.0" + } + }, + { + "identity" : "textual", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/textual", + "state" : { + "revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38", + "version" : "0.3.1" + } + } + ], + "version" : 3 +} diff --git a/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp/MessageListSimulatorApp.swift b/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp/MessageListSimulatorApp.swift new file mode 100644 index 00000000..30e9118c --- /dev/null +++ b/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp/MessageListSimulatorApp.swift @@ -0,0 +1,165 @@ +import MessageList +import SwiftUI + +@main +struct MessageListSimulatorApp: App { + var body: some Scene { + WindowGroup { + MessageListManualTestView() + } + } +} + +private struct MessageListManualTestView: View { + @State private var messages: [DemoMessage] = DemoMessage.seedMessages + @State private var isStreaming = false + @State private var shouldScrollToBottom = false + @State private var isAtBottom = false + @State private var responseNumber = 0 + @State private var scrollRequestTask: Task? + @State private var streamTask: Task? + + var body: some View { + VStack(spacing: 0) { + toolbar + + Divider() + + MessageList( + messages: messages, + isStreaming: isStreaming, + shouldScrollToBottom: shouldScrollToBottom, + isAtBottom: $isAtBottom + ) { message in + DemoMessageRow(message: message) + .padding(.horizontal, 16) + .padding(.vertical, 6) + } + .background(Color(uiColor: .systemBackground)) + } + } + + private var toolbar: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Button("User") { + appendUserMessage() + } + .buttonStyle(.borderedProminent) + + Button("Response") { + appendAssistantMessage() + } + .buttonStyle(.bordered) + + Button(isStreaming ? "Stop" : "Stream") { + isStreaming ? stopStreaming() : startStreaming() + } + .buttonStyle(.bordered) + + Spacer() + + Button("Bottom") { + requestScrollToBottom() + } + .buttonStyle(.bordered) + } + + HStack(spacing: 12) { + Text("At bottom: \(isAtBottom ? "Yes" : "No")") + Text("Messages: \(messages.count)") + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(12) + .background(Color(uiColor: .secondarySystemBackground)) + } + + private func appendUserMessage() { + messages.append(DemoMessage(text: "User message \(messages.count + 1)", isUserMessage: true)) + requestScrollToBottom() + } + + private func appendAssistantMessage() { + responseNumber += 1 + let wasAtBottom = isAtBottom + messages.append(DemoMessage(text: "\(responseNumber)", isUserMessage: false)) + if wasAtBottom { + requestScrollToBottom() + } + } + + private func startStreaming() { + streamTask?.cancel() + appendUserMessage() + isStreaming = true + + streamTask = Task { @MainActor in + for index in 1...10 { + try? await Task.sleep(for: .milliseconds(250)) + guard !Task.isCancelled, isStreaming else { return } + let wasAtBottom = isAtBottom + messages.append(DemoMessage(text: "stream \(index)", isUserMessage: false)) + if wasAtBottom { + requestScrollToBottom() + } + } + isStreaming = false + } + } + + private func stopStreaming() { + streamTask?.cancel() + streamTask = nil + isStreaming = false + } + + private func requestScrollToBottom() { + scrollRequestTask?.cancel() + shouldScrollToBottom = false + scrollRequestTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(20)) + guard !Task.isCancelled else { return } + shouldScrollToBottom = true + } + } +} + +private struct DemoMessageRow: View { + let message: DemoMessage + + var body: some View { + HStack(alignment: .top) { + if message.isUserMessage { + Spacer(minLength: 48) + } + + Text(message.text) + .font(.body) + .foregroundStyle(message.isUserMessage ? .white : .primary) + .frame(minHeight: 44, alignment: .center) + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background { + RoundedRectangle(cornerRadius: 8) + .fill(message.isUserMessage ? Color.accentColor : Color(uiColor: .secondarySystemBackground)) + } + .accessibilityIdentifier("message-\(message.text)") + + if !message.isUserMessage { + Spacer(minLength: 48) + } + } + } +} + +private struct DemoMessage: MessageListItem { + let id = UUID() + var text: String + var isUserMessage: Bool + + static let seedMessages: [DemoMessage] = (1...36).map { + DemoMessage(text: "Seed \($0)", isUserMessage: $0.isMultiple(of: 5)) + } +} diff --git a/Packages/Examples/MessageListSimulatorApp/README.md b/Packages/Examples/MessageListSimulatorApp/README.md new file mode 100644 index 00000000..b95434c4 --- /dev/null +++ b/Packages/Examples/MessageListSimulatorApp/README.md @@ -0,0 +1,21 @@ +# MessageList Simulator App + +Small iOS host app for manually testing `MessageList` in Simulator. + +Open: + +```bash +open Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj +``` + +Then select the `MessageListSimulatorApp` scheme and an iOS Simulator. + +Command-line build example: + +```bash +xcodebuild build \ + -project Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj \ + -scheme MessageListSimulatorApp \ + -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.1' +``` + diff --git a/Packages/Package.swift b/Packages/Package.swift index 02dfbd44..b647129a 100644 --- a/Packages/Package.swift +++ b/Packages/Package.swift @@ -6,6 +6,7 @@ let package = Package( defaultLocalization: "en", platforms: [.macOS(.v15), .iOS(.v18)], products: [ + .library(name: "MessageList", targets: ["MessageList"]), .library(name: "RxCodeCore", targets: ["RxCodeCore"]), .library(name: "RxCodeChatKit", targets: ["RxCodeChatKit"]), .library(name: "RxCodeSync", targets: ["RxCodeSync"]), @@ -16,6 +17,13 @@ let package = Package( .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.4.1"), ], targets: [ + .target( + name: "MessageList", + path: "Sources/MessageList", + swiftSettings: [ + .defaultIsolation(MainActor.self), + ] + ), .target( name: "RxCodeCore", path: "Sources/RxCodeCore" @@ -23,6 +31,7 @@ let package = Package( .target( name: "RxCodeChatKit", dependencies: [ + "MessageList", "RxCodeCore", .product(name: "Textual", package: "textual"), .product(name: "MarkdownUI", package: "swift-markdown-ui"), @@ -40,6 +49,14 @@ let package = Package( dependencies: ["RxCodeCore"], path: "Sources/RxCodeSync" ), + .testTarget( + name: "MessageListTests", + dependencies: [ + "MessageList", + .product(name: "ViewInspector", package: "ViewInspector"), + ], + path: "Tests/MessageListTests" + ), .testTarget( name: "RxCodeCoreTests", dependencies: ["RxCodeCore"], diff --git a/Packages/Sources/MessageList/MessageList.swift b/Packages/Sources/MessageList/MessageList.swift new file mode 100644 index 00000000..3ce19013 --- /dev/null +++ b/Packages/Sources/MessageList/MessageList.swift @@ -0,0 +1,501 @@ +import Foundation +import SwiftUI + +public protocol MessageListItem: Identifiable, Sendable where ID: Hashable & Sendable { + var isUserMessage: Bool { get } + var isMessageListAccessory: Bool { get } +} + +public extension MessageListItem { + var isMessageListAccessory: Bool { false } +} + +public enum MessageListLoadDirection: Sendable, Equatable { + case previous + case next +} + +public struct MessageList: View { + private let messages: [Message] + private let isStreaming: Bool + private let shouldScrollToBottom: Bool + @Binding private var isAtBottom: Bool + private let hasMorePrevious: () -> Bool + private let hasMore: () -> Bool + private let loadMorePrevious: (() async throws -> Void)? + private let loadMore: (() async throws -> Void)? + private let onLoadError: (MessageListLoadDirection, Error) -> Void + private let rowContent: (Message) -> RowContent + + @State private var anchor = MessageListScrollAnchor() + @State private var pinning = MessageListPinningController() + @State private var scrollPhase: ScrollPhase = .idle + @State private var scrollViewHeight: CGFloat = 0 + @State private var latestUserMinY: CGFloat = 0 + @State private var tailMarkerMinY: CGFloat = 0 + @State private var activeTurnMaxMeasuredHeight: CGFloat = 0 + @State private var canReleasePinnedUserMessageByScroll = false + @State private var pinTask: Task? + @State private var bottomScrollTask: Task? + @State private var lastStreamingBottomScrollDate = Date.distantPast + @State private var isLoadingPrevious = false + @State private var isLoadingNext = false + @State private var previousLoadContentHeight: CGFloat? + @State private var nextLoadContentHeight: CGFloat? + + public init( + messages: [Message], + isStreaming: Bool = false, + shouldScrollToBottom: Bool = false, + isAtBottom: Binding = .constant(true), + hasMorePrevious: @escaping () -> Bool = { false }, + hasMore: @escaping () -> Bool = { false }, + loadMorePrevious: (() async throws -> Void)? = nil, + loadMore: (() async throws -> Void)? = nil, + onLoadError: @escaping (MessageListLoadDirection, Error) -> Void = { _, _ in }, + @ViewBuilder rowContent: @escaping (Message) -> RowContent + ) { + self.messages = messages + self.isStreaming = isStreaming + self.shouldScrollToBottom = shouldScrollToBottom + self._isAtBottom = isAtBottom + self.hasMorePrevious = hasMorePrevious + self.hasMore = hasMore + self.loadMorePrevious = loadMorePrevious + self.loadMore = loadMore + self.onLoadError = onLoadError + self.rowContent = rowContent + } + + public var body: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + topLoadTrigger + + ForEach(messages) { message in + let messageID = message.id + rowContent(message) + .onGeometryChange(for: CGFloat.self) { geometry in + geometry.frame(in: .named(MessageListConstants.coordinateSpaceName)).minY + } action: { value in + guard messageID == pinning.pinnedUserMessageID else { return } + updateLatestUserMinY(value) + } + .id(messageID) + } + + tailMarker + bottomLoadTrigger + pinTailSpacer + bottomAnchor + } + .coordinateSpace(.named(MessageListConstants.coordinateSpaceName)) + } + .onGeometryChange(for: CGFloat.self) { geometry in + geometry.size.height + } action: { height in + scrollViewHeight = height + updateActiveTurnMaxMeasuredHeight() + } + .onScrollGeometryChange(for: MessageListScrollMetrics.self) { geometry in + MessageListScrollMetrics( + contentHeight: geometry.contentSize.height, + visibleMinY: geometry.visibleRect.minY, + visibleMaxY: geometry.visibleRect.maxY + ) + } action: { _, metrics in + handleScrollMetrics(metrics, proxy: proxy) + } + .onScrollGeometryChange(for: CGFloat.self) { geometry in + geometry.contentOffset.y + } action: { oldOffsetY, offsetY in + guard isDirectUserScroll, + pinning.isPinningUserMessage, + canReleasePinnedUserMessageByScroll, + offsetY > oldOffsetY + MessageListConstants.userScrollDownDelta + else { return } + releasePinnedUserMessage(proxy: proxy) + } + .onScrollPhaseChange { _, phase in + scrollPhase = phase + } + .task { + if shouldScrollToBottom { + scrollToBottom(proxy: proxy, animated: false) + } + } + .onChange(of: shouldScrollToBottom) { _, shouldScroll in + guard shouldScroll else { return } + guard !pinning.isPinningUserMessage else { return } + anchor.resetToBottom() + scrollToBottom(proxy: proxy, animated: true) + } + .onChange(of: isStreaming) { oldValue, newValue in + if !newValue { + lastStreamingBottomScrollDate = .distantPast + } + applyPinningAction( + pinning.handleStreamingChange( + oldValue: oldValue, + newValue: newValue, + isAtBottom: isAnchoredAtBottom + ), + proxy: proxy + ) + } + .onChange(of: messageListChangeToken) { oldToken, newToken in + handleMessageListChange(oldToken: oldToken, newToken: newToken, proxy: proxy) + } + } + } + + private var topLoadTrigger: some View { + Color.clear.frame(height: 1) + } + + private var tailMarker: some View { + Color.clear + .frame(height: 1) + .id(MessageListConstants.tailMarkerID) + .onGeometryChange(for: CGFloat.self) { geometry in + geometry.frame(in: .named(MessageListConstants.coordinateSpaceName)).minY + } action: { value in + updateTailMarkerMinY(value) + } + } + + private var bottomLoadTrigger: some View { + Color.clear.frame(height: 1) + } + + private var pinTailSpacer: some View { + Color.clear.frame(height: pinTailSpacerHeight) + } + + private var bottomAnchor: some View { + Color.clear + .frame(height: 1) + .id(MessageListConstants.bottomAnchorID) + } + + private var isUserDrivenScroll: Bool { + switch scrollPhase { + case .interacting, .tracking, .decelerating: + true + case .idle, .animating: + false + @unknown default: + false + } + } + + private var isDirectUserScroll: Bool { + switch scrollPhase { + case .interacting, .tracking: + true + case .idle, .animating, .decelerating: + false + @unknown default: + false + } + } + + private var pinTailSpacerHeight: CGFloat { + guard pinning.pinnedUserMessageID != nil, scrollViewHeight > 0 else { return 0 } + let turnHeight = max(activeTurnMaxMeasuredHeight, rawActiveTurnMeasuredHeight) + return max(0, scrollViewHeight - turnHeight - 16) + } + + private var rawActiveTurnMeasuredHeight: CGFloat { + max(0, tailMarkerMinY - latestUserMinY) + } + + private var isAnchoredAtBottom: Bool { + anchor.isNearBottom && isAtBottom + } + + private var latestContentItem: Message? { + messages.last { !$0.isMessageListAccessory } + } + + private var latestUserMessageID: Message.ID? { + messages.last { $0.isUserMessage }?.id + } + + private var messageListChangeToken: MessageListChangeToken { + MessageListChangeToken( + ids: messages.map(\.id), + latestContentID: latestContentItem?.id, + latestUserMessageID: latestUserMessageID + ) + } + + private func handleScrollMetrics(_ metrics: MessageListScrollMetrics, proxy: ScrollViewProxy) { + let decision = anchor.apply( + contentHeight: metrics.contentHeight, + visibleMaxY: metrics.visibleMaxY + ) + updateIsAtBottomBinding(anchor.isNearBottom) + if decision == .scrollToBottom, + isAtBottom, + isStreaming, + !isUserDrivenScroll, + !pinning.isPinningUserMessage { + scheduleScrollToBottom(proxy: proxy) + } + + if isUserDrivenScroll, metrics.visibleMinY <= MessageListConstants.loadThreshold { + triggerLoadPreviousIfNeeded(contentHeight: metrics.contentHeight) + } else if metrics.visibleMinY > MessageListConstants.loadThreshold { + previousLoadContentHeight = nil + } + + if isUserDrivenScroll, metrics.contentHeight - metrics.visibleMaxY <= MessageListConstants.loadThreshold { + triggerLoadNextIfNeeded(contentHeight: metrics.contentHeight) + } else if metrics.contentHeight - metrics.visibleMaxY > MessageListConstants.loadThreshold { + nextLoadContentHeight = nil + } + } + + private func handleMessageListChange( + oldToken: MessageListChangeToken, + newToken: MessageListChangeToken, + proxy: ScrollViewProxy + ) { + let latestContentItem = latestContentItem + if oldToken.latestUserMessageID != newToken.latestUserMessageID, + let latestUserMessageID = newToken.latestUserMessageID, + isStreaming || latestContentItem?.isUserMessage == true { + let action = pinning.handleLastMessageChange( + id: latestUserMessageID, + isUserMessage: true, + isStreaming: isStreaming, + isAtBottom: isAnchoredAtBottom + ) + applyPinningAction(action, proxy: proxy) + return + } + + let action = pinning.handleLastMessageChange( + id: latestContentItem?.id, + isUserMessage: latestContentItem?.isUserMessage == true, + isStreaming: isStreaming, + isAtBottom: isAnchoredAtBottom + ) + applyPinningAction(action, proxy: proxy) + } + + private func updateIsAtBottomBinding(_ value: Bool) { + guard isAtBottom != value else { return } + isAtBottom = value + } + + private func scrollToBottom(proxy: ScrollViewProxy, animated: Bool) { + if animated { + withAnimation(.easeInOut(duration: MessageListConstants.scrollAnimationSeconds)) { + proxy.scrollTo(MessageListConstants.bottomAnchorID, anchor: .bottom) + } + } else { + proxy.scrollTo(MessageListConstants.bottomAnchorID, anchor: .bottom) + } + } + + private func scheduleScrollToBottom(proxy: ScrollViewProxy) { + guard bottomScrollTask == nil else { return } + let delayNanoseconds = scrollToBottomDelayNanoseconds() + bottomScrollTask = Task { @MainActor in + if delayNanoseconds > 0 { + try? await Task.sleep(nanoseconds: delayNanoseconds) + } + guard !Task.isCancelled else { return } + bottomScrollTask = nil + guard isAnchoredAtBottom, !isUserDrivenScroll, !pinning.isPinningUserMessage else { return } + if isStreaming { + lastStreamingBottomScrollDate = Date() + } + scrollToBottom(proxy: proxy, animated: true) + } + } + + private func scrollToBottomDelayNanoseconds() -> UInt64 { + guard isStreaming else { + return MessageListConstants.layoutSettleDelayNanoseconds + } + let elapsed = Date().timeIntervalSince(lastStreamingBottomScrollDate) + let delay = max(0, MessageListConstants.streamingBottomScrollInterval - elapsed) + return UInt64(delay * 1_000_000_000) + } + + private func pinUserMessageToTop(_ id: Message.ID, proxy: ScrollViewProxy, animated: Bool) { + bottomScrollTask?.cancel() + bottomScrollTask = nil + pinTask?.cancel() + canReleasePinnedUserMessageByScroll = false + + pinTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(16)) + guard !Task.isCancelled else { return } + + if animated { + withAnimation(.easeInOut(duration: MessageListConstants.pinAnimationSeconds)) { + proxy.scrollTo(id, anchor: .top) + } + try? await Task.sleep(for: MessageListConstants.pinAnimationDuration) + } + + for _ in 0..<8 { + guard !Task.isCancelled else { return } + var transaction = Transaction() + transaction.animation = nil + withTransaction(transaction) { + proxy.scrollTo(id, anchor: .top) + } + try? await Task.sleep(for: .milliseconds(16)) + } + + guard !Task.isCancelled, pinning.isPinningUserMessage else { return } + canReleasePinnedUserMessageByScroll = true + } + } + + private func releasePinnedUserMessage(proxy: ScrollViewProxy) { + pinTask?.cancel() + canReleasePinnedUserMessageByScroll = false + pinning.releasePin() + anchor.resetToBottom() + updateIsAtBottomBinding(true) + scrollToBottom(proxy: proxy, animated: true) + } + + private func clearPinnedUserMessage() { + pinTask?.cancel() + pinning.clear() + latestUserMinY = 0 + tailMarkerMinY = 0 + activeTurnMaxMeasuredHeight = 0 + canReleasePinnedUserMessageByScroll = false + } + + private func applyPinningAction( + _ action: MessageListPinningAction, + proxy: ScrollViewProxy + ) { + switch action { + case .none: + break + case .clearPin: + clearPinnedUserMessage() + case .pinUserMessageToTop(let id): + resetPinnedTurnMeasurements() + canReleasePinnedUserMessageByScroll = false + pinUserMessageToTop(id, proxy: proxy, animated: true) + case .repinUserMessageToTop(let id): + pinUserMessageToTop(id, proxy: proxy, animated: false) + case .releasePinAndScrollToBottom: + releasePinnedUserMessage(proxy: proxy) + case .scrollToBottom: + scheduleScrollToBottom(proxy: proxy) + } + } + + private func resetPinnedTurnMeasurements() { + latestUserMinY = 0 + tailMarkerMinY = 0 + activeTurnMaxMeasuredHeight = 0 + } + + private func updateLatestUserMinY(_ value: CGFloat) { + guard abs(value - latestUserMinY) > 0.5 else { return } + var transaction = Transaction() + transaction.animation = nil + withTransaction(transaction) { + latestUserMinY = value + } + updateActiveTurnMaxMeasuredHeight() + } + + private func updateTailMarkerMinY(_ value: CGFloat) { + guard abs(value - tailMarkerMinY) > 0.5 else { return } + var transaction = Transaction() + transaction.animation = nil + withTransaction(transaction) { + tailMarkerMinY = value + } + updateActiveTurnMaxMeasuredHeight() + } + + private func updateActiveTurnMaxMeasuredHeight() { + guard pinning.pinnedUserMessageID != nil else { return } + let measured = rawActiveTurnMeasuredHeight + guard measured > activeTurnMaxMeasuredHeight + 0.5 else { return } + var transaction = Transaction() + transaction.animation = nil + withTransaction(transaction) { + activeTurnMaxMeasuredHeight = measured + } + } + + private func triggerLoadPreviousIfNeeded(contentHeight: CGFloat) { + guard !isLoadingPrevious, + hasMorePrevious(), + let loadMorePrevious, + previousLoadContentHeight != contentHeight + else { return } + + previousLoadContentHeight = contentHeight + isLoadingPrevious = true + Task { @MainActor in + defer { isLoadingPrevious = false } + do { + try await loadMorePrevious() + } catch { + onLoadError(.previous, error) + } + } + } + + private func triggerLoadNextIfNeeded(contentHeight: CGFloat) { + guard !isLoadingNext, + hasMore(), + let loadMore, + nextLoadContentHeight != contentHeight + else { return } + + nextLoadContentHeight = contentHeight + isLoadingNext = true + Task { @MainActor in + defer { isLoadingNext = false } + do { + try await loadMore() + } catch { + onLoadError(.next, error) + } + } + } +} + +nonisolated struct MessageListScrollMetrics: Equatable { + var contentHeight: CGFloat + var visibleMinY: CGFloat + var visibleMaxY: CGFloat +} + +private nonisolated struct MessageListChangeToken: Equatable { + var ids: [ID] + var latestContentID: ID? + var latestUserMessageID: ID? +} + +private nonisolated enum MessageListConstants { + static let bottomAnchorID = "message-list-bottom-anchor" + static let tailMarkerID = "message-list-tail-marker" + static let coordinateSpaceName = "message-list-content" + static let loadThreshold: CGFloat = 96 + static let userScrollDownDelta: CGFloat = 4 + static let layoutSettleDelayNanoseconds: UInt64 = 16_000_000 + static let streamingBottomScrollInterval: TimeInterval = 2 + static let scrollAnimationSeconds: Double = 0.24 + static let pinAnimationDuration: Duration = .milliseconds(320) + static let pinAnimationSeconds: Double = 0.32 +} diff --git a/Packages/Sources/MessageList/MessageListPinningController.swift b/Packages/Sources/MessageList/MessageListPinningController.swift new file mode 100644 index 00000000..72a9fcfd --- /dev/null +++ b/Packages/Sources/MessageList/MessageListPinningController.swift @@ -0,0 +1,67 @@ +nonisolated enum MessageListPinningAction: Equatable { + case none + case clearPin + case pinUserMessageToTop(ID) + case repinUserMessageToTop(ID) + case releasePinAndScrollToBottom + case scrollToBottom +} + +nonisolated struct MessageListPinningController: Equatable { + private(set) var pinnedUserMessageID: ID? + private(set) var isPinningUserMessage: Bool + + init(pinnedUserMessageID: ID? = nil, isPinningUserMessage: Bool = false) { + self.pinnedUserMessageID = pinnedUserMessageID + self.isPinningUserMessage = isPinningUserMessage + } + + mutating func handleLastMessageChange( + id: ID?, + isUserMessage: Bool, + isStreaming: Bool, + isAtBottom: Bool + ) -> MessageListPinningAction { + guard let id else { + clear() + return .clearPin + } + + if isUserMessage { + pinnedUserMessageID = id + isPinningUserMessage = true + return .pinUserMessageToTop(id) + } + + guard isPinningUserMessage, let pinnedUserMessageID else { + return isAtBottom ? .scrollToBottom : .none + } + + if isStreaming { + return .repinUserMessageToTop(pinnedUserMessageID) + } + + releasePin() + return .releasePinAndScrollToBottom + } + + mutating func handleStreamingChange( + oldValue: Bool, + newValue: Bool, + isAtBottom: Bool + ) -> MessageListPinningAction { + guard oldValue && !newValue else { return .none } + guard isPinningUserMessage else { return isAtBottom ? .scrollToBottom : .none } + releasePin() + return .releasePinAndScrollToBottom + } + + mutating func releasePin() { + isPinningUserMessage = false + } + + mutating func clear() { + pinnedUserMessageID = nil + isPinningUserMessage = false + } +} diff --git a/Packages/Sources/MessageList/MessageListPreview.swift b/Packages/Sources/MessageList/MessageListPreview.swift new file mode 100644 index 00000000..ca726fd9 --- /dev/null +++ b/Packages/Sources/MessageList/MessageListPreview.swift @@ -0,0 +1,209 @@ +#if DEBUG +import SwiftUI + +nonisolated struct MessageListPreviewMessage: MessageListItem { + let id: UUID + var text: String + var isUserMessage: Bool + + init(id: UUID = UUID(), text: String, isUserMessage: Bool) { + self.id = id + self.text = text + self.isUserMessage = isUserMessage + } +} + +struct MessageListPreviewHarness: View { + private let enablesPagination: Bool + + @State private var messages = MessageListPreviewMessage.initialMessages + @State private var isStreaming = false + @State private var shouldScrollToBottom = false + @State private var isAtBottom = true + @State private var previousPage = 0 + @State private var nextPage = 0 + @State private var scrollRequestTask: Task? + @State private var streamTask: Task? + + init(enablesPagination: Bool = false) { + self.enablesPagination = enablesPagination + } + + var body: some View { + VStack(spacing: 0) { + toolbar + + Divider() + MessageList( + messages: messages, + isStreaming: isStreaming, + shouldScrollToBottom: shouldScrollToBottom, + isAtBottom: $isAtBottom, + hasMorePrevious: { enablesPagination && previousPage < 2 }, + hasMore: { enablesPagination && nextPage < 2 }, + loadMorePrevious: loadMorePrevious, + loadMore: loadMoreNext + ) { message in + MessageListPreviewRow(message: message) + .padding(.horizontal, 16) + .padding(.vertical, 6) + } + .background(previewBackgroundColor) + } + .frame(minWidth: 420, minHeight: 640) + .onDisappear { + scrollRequestTask?.cancel() + streamTask?.cancel() + } + } + + private var previewBackgroundColor: Color { + #if os(macOS) + Color(nsColor: .textBackgroundColor) + #else + Color(.systemBackground) + #endif + } + + private var toolbar: some View { + HStack(spacing: 8) { + Button("User") { + appendUserMessage() + } + Button("Response") { + appendMockResponse() + } + Button(isStreaming ? "Stop" : "Stream") { + isStreaming ? stopStreaming() : startMockStream() + } + Spacer() + Button("Bottom") { + requestScrollToBottom() + } + } + .buttonStyle(.bordered) + .padding(12) + } + + private func appendUserMessage() { + messages.append(.init(text: "Can you summarize the recent changes?", isUserMessage: true)) + requestScrollToBottom() + } + + private func appendMockResponse() { + let wasAtBottom = isAtBottom + messages.append(.mockAssistantResponse(number: messages.count)) + if wasAtBottom { + requestScrollToBottom() + } + } + + private func startMockStream() { + streamTask?.cancel() + messages.append(.init(text: "Please draft a short implementation plan.", isUserMessage: true)) + isStreaming = true + requestScrollToBottom() + + streamTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(700)) + guard isStreaming else { return } + messages.append(.mockStreamingResponse) + isStreaming = false + requestScrollToBottom() + } + } + + private func stopStreaming() { + isStreaming = false + } + + private func requestScrollToBottom() { + scrollRequestTask?.cancel() + shouldScrollToBottom = false + scrollRequestTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(10)) + guard !Task.isCancelled else { return } + shouldScrollToBottom = true + } + } + + private func loadMorePrevious() async throws { + try await Task.sleep(for: .milliseconds(350)) + let page = previousPage + 1 + previousPage = page + messages.insert(contentsOf: MessageListPreviewMessage.previousPage(page), at: 0) + } + + private func loadMoreNext() async throws { + try await Task.sleep(for: .milliseconds(350)) + let page = nextPage + 1 + nextPage = page + messages.append(contentsOf: MessageListPreviewMessage.nextPage(page)) + } +} + +struct MessageListPreviewRow: View { + let message: MessageListPreviewMessage + + var body: some View { + HStack(alignment: .top) { + if message.isUserMessage { + Spacer(minLength: 48) + } + + Text(message.text) + .font(.body) + .foregroundStyle(message.isUserMessage ? .white : .primary) + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background { + RoundedRectangle(cornerRadius: 8) + .fill(message.isUserMessage ? Color.accentColor : Color.secondary.opacity(0.14)) + } + + if !message.isUserMessage { + Spacer(minLength: 48) + } + } + } +} + +extension MessageListPreviewMessage { + static let initialMessages: [MessageListPreviewMessage] = [ + .init(text: "Create a reusable message list package.", isUserMessage: true), + .init(text: "I added a generic SwiftUI list that accepts caller-provided row rendering, handles bottom anchoring, supports pagination, and pins the latest user turn while the response grows.", isUserMessage: false), + .init(text: "Can we test it with mock paging and responses?", isUserMessage: true), + .init(text: "Yes. This preview includes top and bottom pagination plus buttons that append user messages and mock assistant responses.", isUserMessage: false), + ] + + static let mockStreamingResponse = MessageListPreviewMessage( + text: "Streaming finished with a mock response. The latest user message should remain pinned near the top until you scroll down.", + isUserMessage: false + ) + + static func mockAssistantResponse(number: Int) -> MessageListPreviewMessage { + MessageListPreviewMessage( + text: "Mock response \(number): here is a longer assistant message so you can see content growth, wrapping, bottom anchoring, and lazy row rendering in the preview canvas.", + isUserMessage: false + ) + } + + static func previousPage(_ page: Int) -> [MessageListPreviewMessage] { + [ + .init(text: "Older user prompt page \(page).", isUserMessage: true), + .init(text: "Older assistant response page \(page). Scroll near the top again to load another previous page.", isUserMessage: false), + ] + } + + static func nextPage(_ page: Int) -> [MessageListPreviewMessage] { + [ + .init(text: "Newer user prompt page \(page).", isUserMessage: true), + .init(text: "Newer assistant response page \(page). Scroll near the bottom again to load another page.", isUserMessage: false), + ] + } +} + +#Preview("MessageList") { + MessageListPreviewHarness() +} +#endif diff --git a/Packages/Sources/MessageList/MessageListScrollAnchor.swift b/Packages/Sources/MessageList/MessageListScrollAnchor.swift new file mode 100644 index 00000000..af26c694 --- /dev/null +++ b/Packages/Sources/MessageList/MessageListScrollAnchor.swift @@ -0,0 +1,49 @@ +import CoreGraphics + +nonisolated struct MessageListScrollAnchor: Equatable { + var threshold: CGFloat + private(set) var isNearBottom: Bool + private(set) var lastContentHeight: CGFloat + private var hasReceivedFirstUpdate: Bool + + enum Decision: Equatable { + case none + case scrollToBottom + } + + init(threshold: CGFloat = 120, isNearBottom: Bool = true) { + self.threshold = threshold + self.isNearBottom = isNearBottom + self.lastContentHeight = 0 + self.hasReceivedFirstUpdate = false + } + + @discardableResult + mutating func apply(contentHeight: CGFloat, visibleMaxY: CGFloat) -> Decision { + let distanceFromBottom = max(0, contentHeight - visibleMaxY) + let nowNearBottom = distanceFromBottom < threshold + + guard hasReceivedFirstUpdate else { + hasReceivedFirstUpdate = true + lastContentHeight = contentHeight + isNearBottom = nowNearBottom + return .none + } + + let grewEpsilon: CGFloat = 0.5 + let contentGrew = contentHeight > lastContentHeight + grewEpsilon + let previouslyNearBottom = isNearBottom + lastContentHeight = contentHeight + + if contentGrew { + return previouslyNearBottom ? .scrollToBottom : .none + } + + isNearBottom = nowNearBottom + return .none + } + + mutating func resetToBottom() { + isNearBottom = true + } +} diff --git a/Packages/Sources/RxCodeChatKit/ChatTranscriptList.swift b/Packages/Sources/RxCodeChatKit/ChatTranscriptList.swift new file mode 100644 index 00000000..bbd0842b --- /dev/null +++ b/Packages/Sources/RxCodeChatKit/ChatTranscriptList.swift @@ -0,0 +1,150 @@ +import MessageList +import RxCodeCore +import SwiftUI + +public nonisolated struct ChatTranscriptAccessory: Identifiable, Hashable, Sendable { + public nonisolated enum Kind: String, Hashable, Sendable { + case topPadding + case loadingPrevious + case loadingNext + case streamingIndicator + case webPreview + case custom + } + + public let id: String + public let kind: Kind + + public init(id: String, kind: Kind) { + self.id = id + self.kind = kind + } +} + +public nonisolated struct ChatTranscriptListItem: Identifiable, MessageListItem, Equatable, Sendable { + public nonisolated enum Kind: Equatable, Sendable { + case message(ChatMessage) + case transientGroup([ChatMessage]) + case accessory(ChatTranscriptAccessory) + } + + public let id: String + public let kind: Kind + + public var isUserMessage: Bool { + guard case .message(let message) = kind else { return false } + return message.role == .user + } + + public var isMessageListAccessory: Bool { + guard case .accessory = kind else { return false } + return true + } + + public static func message(_ message: ChatMessage) -> ChatTranscriptListItem { + ChatTranscriptListItem(id: "message-\(message.id.uuidString)", kind: .message(message)) + } + + public static func transientGroup(_ messages: [ChatMessage]) -> ChatTranscriptListItem? { + guard let first = messages.first else { return nil } + return ChatTranscriptListItem(id: "group-\(first.id.uuidString)", kind: .transientGroup(messages)) + } + + public static func accessory(_ accessory: ChatTranscriptAccessory) -> ChatTranscriptListItem { + ChatTranscriptListItem(id: "accessory-\(accessory.id)", kind: .accessory(accessory)) + } + + @MainActor public static func items( + for messages: [ChatMessage], + transientGroupMinSize: Int = 2 + ) -> [ChatTranscriptListItem] { + chatMessageGroups(messages, minGroupSize: transientGroupMinSize).compactMap { group in + if group.isTransientGroup { + return transientGroup(group.messages) + } + guard let message = group.messages.first else { return nil } + return .message(message) + } + } +} + +public struct ChatTranscriptList: View { + private let items: [ChatTranscriptListItem] + private let isStreaming: Bool + private let shouldScrollToBottom: Bool + @Binding private var isAtBottom: Bool + private let hasMorePrevious: () -> Bool + private let loadMorePrevious: (() async throws -> Void)? + private let onLoadError: (MessageListLoadDirection, Error) -> Void + private let rowPadding: EdgeInsets + private let accessibilityIdentifier: String? + private let accessoryContent: (ChatTranscriptAccessory) -> AccessoryContent + + public init( + items: [ChatTranscriptListItem], + isStreaming: Bool = false, + shouldScrollToBottom: Bool = false, + isAtBottom: Binding = .constant(true), + hasMorePrevious: @escaping () -> Bool = { false }, + loadMorePrevious: (() async throws -> Void)? = nil, + onLoadError: @escaping (MessageListLoadDirection, Error) -> Void = { _, _ in }, + rowPadding: EdgeInsets? = nil, + accessibilityIdentifier: String? = nil, + @ViewBuilder accessoryContent: @escaping (ChatTranscriptAccessory) -> AccessoryContent + ) { + self.items = items + self.isStreaming = isStreaming + self.shouldScrollToBottom = shouldScrollToBottom + self._isAtBottom = isAtBottom + self.hasMorePrevious = hasMorePrevious + self.loadMorePrevious = loadMorePrevious + self.onLoadError = onLoadError + self.rowPadding = rowPadding ?? Self.defaultRowPadding + self.accessibilityIdentifier = accessibilityIdentifier + self.accessoryContent = accessoryContent + } + + public var body: some View { + MessageList( + messages: items, + isStreaming: isStreaming, + shouldScrollToBottom: shouldScrollToBottom, + isAtBottom: $isAtBottom, + hasMorePrevious: hasMorePrevious, + loadMorePrevious: loadMorePrevious, + onLoadError: onLoadError + ) { item in + rowContent(for: item) + .padding(rowPadding) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier(accessibilityIdentifier ?? "") + } + + @ViewBuilder + private func rowContent(for item: ChatTranscriptListItem) -> some View { + switch item.kind { + case .message(let message): + ChatMessageBubble(message: message) + .transition(messageFadeTransition(role: message.role)) + case .transientGroup(let messages): + ChatTransientGroupSummaryView(messages: messages) + .transition(messageFadeTransition(role: .assistant)) + case .accessory(let accessory): + accessoryContent(accessory) + } + } + + private func messageFadeTransition(role: Role) -> AnyTransition { + let anchor: UnitPoint = role == .user ? .bottomTrailing : .bottomLeading + return .opacity.combined(with: .scale(scale: 0.97, anchor: anchor)) + } + + private static var defaultRowPadding: EdgeInsets { + #if os(macOS) + EdgeInsets(top: 8, leading: 20, bottom: 24, trailing: 20) + #else + EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16) + #endif + } +} diff --git a/Packages/Sources/RxCodeChatKit/MessageBubble.swift b/Packages/Sources/RxCodeChatKit/MessageBubble.swift index e1fb981f..3df0d766 100644 --- a/Packages/Sources/RxCodeChatKit/MessageBubble.swift +++ b/Packages/Sources/RxCodeChatKit/MessageBubble.swift @@ -296,10 +296,11 @@ struct MessageBubble: View { // MARK: - Assistant Text Bubble private func assistantTextBubble(text: String, blockId: String, showsCursor: Bool) -> some View { - MarkdownContentView( + StreamingFadeInMarkdownContent( text: text, showsTrailingCursor: showsCursor, - isCursorVisible: cursorVisible + isCursorVisible: cursorVisible, + isStreaming: message.isStreaming ) .frame(maxWidth: .infinity, alignment: .leading) .foregroundStyle(ClaudeTheme.textPrimary) @@ -322,6 +323,104 @@ struct MessageBubble: View { .accessibilityLabel("Assistant: \(text)") } + private struct StreamingFadeInMarkdownContent: View { + let text: String + let showsTrailingCursor: Bool + let isCursorVisible: Bool + let isStreaming: Bool + + private static let fadeDuration: TimeInterval = 0.35 + + @State private var committedText: String + @State private var fadingText: String? + @State private var fadingOpacity: Double = 1 + @State private var fadeTask: Task? + + init(text: String, showsTrailingCursor: Bool, isCursorVisible: Bool, isStreaming: Bool) { + self.text = text + self.showsTrailingCursor = showsTrailingCursor + self.isCursorVisible = isCursorVisible + self.isStreaming = isStreaming + _committedText = State(initialValue: text) + } + + var body: some View { + content + .onChange(of: text) { _, newText in + guard newText != currentText else { return } + if isStreaming { + fadeIn(newText) + } else { + fadeTask?.cancel() + committedText = newText + fadingText = nil + fadingOpacity = 1 + } + } + .onChange(of: isStreaming) { _, streaming in + guard !streaming else { return } + fadeTask?.cancel() + committedText = text + fadingText = nil + fadingOpacity = 1 + } + .onDisappear { + fadeTask?.cancel() + } + } + + @ViewBuilder + private var content: some View { + if let fadingText { + ZStack(alignment: .topLeading) { + MarkdownContentView( + text: fadingText, + showsTrailingCursor: showsTrailingCursor, + isCursorVisible: isCursorVisible + ) + .opacity(fadingOpacity) + + MarkdownContentView( + text: committedText, + showsTrailingCursor: false, + isCursorVisible: false + ) + } + } else { + MarkdownContentView( + text: committedText, + showsTrailingCursor: showsTrailingCursor, + isCursorVisible: isCursorVisible + ) + } + } + + private var currentText: String { + fadingText ?? committedText + } + + private func fadeIn(_ newText: String) { + fadeTask?.cancel() + committedText = currentText + fadingText = newText + fadingOpacity = 0 + fadeTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(16)) + guard !Task.isCancelled else { return } + withAnimation(.easeOut(duration: Self.fadeDuration)) { + fadingOpacity = 1 + } + try? await Task.sleep(for: .milliseconds(Int(Self.fadeDuration * 1_000))) + guard !Task.isCancelled, fadingText == newText else { return } + withTransaction(Transaction(animation: nil)) { + committedText = newText + fadingText = nil + fadingOpacity = 1 + } + } + } + } + private func streamingCursorBlockId(in renderBlocks: [AssistantRenderBlock]) -> String? { guard message.isStreaming, latestStreamingAssistantMessageId == message.id, diff --git a/Packages/Sources/RxCodeChatKit/MessageListView.swift b/Packages/Sources/RxCodeChatKit/MessageListView.swift index c478f321..99e9b522 100644 --- a/Packages/Sources/RxCodeChatKit/MessageListView.swift +++ b/Packages/Sources/RxCodeChatKit/MessageListView.swift @@ -43,20 +43,21 @@ struct MessageListView: View { @State private var pendingIndicatorSpacerReduction: CGFloat = 0 @State private var activeTurnMaxMeasuredHeight: CGFloat = 0 @State private var lastBottomScrollDate = Date.distantPast + @State private var shouldScrollToBottom = false + @State private var isAtBottom = true + @State private var scrollRequestTask: Task? private static let log = Logger(subsystem: "com.claudework", category: "MessageListView") private static let bottomAnchorID = "message-list-bottom-anchor" private static let endOfScreenAnchorID = "message-list-end-of-screen" private static let userScrollDownDelta: CGFloat = 4 private static let streamingIndicatorEstimatedHeight: CGFloat = 36 - private static let contentGrowthBottomScrollInterval: TimeInterval = 2 + private static let contentGrowthBottomScrollInterval: TimeInterval = 1 private static let pinToTopAnimationDuration: Duration = .milliseconds(320) private static let pinToTopAnimationSeconds: Double = 0.32 var body: some View { - ScrollViewReader { proxy in - messageList(proxy: proxy) - } + messageList } /// The rows inside the scroll content — extracted so the type-checker handles the @@ -127,79 +128,36 @@ struct MessageListView: View { .id(Self.bottomAnchorID) } - private func messageList(proxy: ScrollViewProxy) -> some View { - let base = ScrollView { - LazyVStack(alignment: .leading, spacing: 0) { - messageListRows - } - .padding(.top, 16) - .coordinateSpace(.named(chatContentCoordinateSpace)) - .environment(\.chatTrackedMessageID, trackedUserMessageID) - .environment(\.chatTrackedMessageGeometry, updateLatestUserMinY) + private var messageList: some View { + let base = ChatTranscriptList( + items: transcriptItems, + isStreaming: chatBridge.isStreaming, + shouldScrollToBottom: shouldScrollToBottom, + isAtBottom: $isAtBottom + ) { accessory in + transcriptAccessory(accessory) } - .opacity(isSessionReady ? 1 : 0) - .onGeometryChange(for: CGFloat.self) { proxy in - proxy.size.height - } action: { newValue in - scrollViewHeight = newValue - } - .onScrollGeometryChange(for: ScrollSample.self) { geo in - ScrollSample(contentHeight: geo.contentSize.height, visibleMaxY: geo.visibleRect.maxY) - } action: { _, sample in - // Route the geometry change through AutoScrollAnchor so content - // growth (streaming text, an Edit/Bash card expanding) keeps the - // list glued to the bottom — only a deliberate user scroll - // un-sticks it. Suppressed while the user drives the scroll so - // auto-scroll never fights a drag. - let decision = anchor.apply(contentHeight: sample.contentHeight, visibleMaxY: sample.visibleMaxY) - if decision == .scrollToBottom, !isUserDrivenScroll, !isPinningLatestTurnToTop { - logScrollState( - "geometry.scrollToBottom", - extra: "contentHeight=\(sample.contentHeight) visibleMaxY=\(sample.visibleMaxY)" - ) - scrollToBottomDebounced(proxy) - } else if decision == .scrollToBottom { - logScrollState( - "geometry.suppressedBottom", - extra: "contentHeight=\(sample.contentHeight) visibleMaxY=\(sample.visibleMaxY) userDriven=\(isUserDrivenScroll) pinning=\(isPinningLatestTurnToTop)" - ) - } - } - .onScrollGeometryChange(for: CGFloat.self) { geo in - geo.contentOffset.y - } action: { oldOffsetY, offsetY in - if isDirectUserScroll, - isPinningLatestTurnToTop, - canReleasePinnedTurnByScroll, - offsetY > oldOffsetY + Self.userScrollDownDelta - { - releasePinnedTurnToBottom(proxy: proxy) - } - } - .onScrollPhaseChange { _, newPhase in - scrollPhase = newPhase - } + .opacity(isSessionReady || shouldShowEndLoadingIndicator ? 1 : 0) let scrolling = base .task(id: windowState.currentSessionId) { - await handleSessionTask(proxy: proxy) + await handleSessionTask() } return scrolling .onChange(of: chatBridge.isLoadingFromDisk) { _, isLoading in - handleLoadingChange(isLoading, proxy: proxy) + handleLoadingChange(isLoading) } .onChange(of: chatBridge.isStreaming) { old, new in - handleStreamingChange(old: old, new: new, proxy: proxy) + handleStreamingChange(old: old, new: new) } .onChange(of: chatBridge.sendRequestID) { _, _ in prepareForNewUserSend() } .onChange(of: chatBridge.messages.last?.id) { _, _ in - handleLastMessageChange(proxy: proxy) + handleLastMessageChange() } .onChange(of: chatBridge.messages.last?.content) { _, _ in guard isSessionReady else { return } - guard !isPinningLatestTurnToTop else { return } - scrollToBottomDebounced(proxy) + requestScrollToBottom() } .onChange(of: isSessionReady) { _, new in Self.log.info("[MessageList.ready] isSessionReady=\(new) sid=\(windowState.currentSessionId ?? "", privacy: .public) settled=\(settledItems.count)") @@ -217,7 +175,7 @@ struct MessageListView: View { // MARK: - Session lifecycle - private func handleSessionTask(proxy: ScrollViewProxy) async { + private func handleSessionTask() async { let sid = windowState.currentSessionId ?? "" Self.log.info("[MessageList.task] fired sid=\(sid, privacy: .public) bridgeMessages=\(chatBridge.messages.count) isStreaming=\(chatBridge.isStreaming) isLoadingFromDisk=\(chatBridge.isLoadingFromDisk)") // When the CLI emits its first `system:init` event mid-stream, AppState @@ -228,19 +186,7 @@ struct MessageListView: View { if chatBridge.isStreaming { rebuildSettledItems() if !isSessionReady { isSessionReady = true } - if let latest = chatBridge.messages.last, latest.role == .user { - startActiveTurn(latest.id) - isPinningLatestTurnToTop = true - pendingIndicatorSpacerReduction = Self.streamingIndicatorEstimatedHeight - updateActiveTurnMaxMeasuredHeight() - pinSentMessageToTop(latest.id, proxy: proxy, animated: true) - } else if repinActiveTurnIfNeeded(proxy: proxy) { - // Keep the mobile-style top pin across the pending-session-id - // to real-session-id swap that can happen after streaming starts. - } else if !chatBridge.messages.isEmpty || !settledItems.isEmpty { - anchor.resetToBottom() - settleAtBottom(proxy: proxy, reason: "sessionTask.streaming") - } + requestScrollToBottom() Self.log.info("[MessageList.task] streaming-path settled=\(settledItems.count) sid=\(sid, privacy: .public)") return } @@ -250,6 +196,7 @@ struct MessageListView: View { settleScrollTask?.cancel() readyTask?.cancel() pinToTopTask?.cancel() + scrollRequestTask?.cancel() activeTurnUserMessageID = nil isPinningLatestTurnToTop = false canReleasePinnedTurnByScroll = false @@ -272,13 +219,13 @@ struct MessageListView: View { // Re-assert the bottom across several frames: a single scroll can fire // before the lazy stack has realized the freshly-rebuilt rows, stranding the // view at the top — which the fade-in would then reveal. - settleAtBottom(proxy: proxy, reason: "sessionOpen") + requestScrollToBottom() anchor.resetToBottom() try? await Task.sleep(for: .milliseconds(48)) // let the first re-asserts land before fade-in withAnimation(.easeIn(duration: 0.15)) { isSessionReady = true } } - private func handleLoadingChange(_ isLoading: Bool, proxy: ScrollViewProxy) { + private func handleLoadingChange(_ isLoading: Bool) { let sid = windowState.currentSessionId ?? "" Self.log.info("[MessageList.onLoadChange] isLoading=\(isLoading) sid=\(sid, privacy: .public) bridgeMessages=\(chatBridge.messages.count) settled=\(settledItems.count)") // When a background disk load finishes for a freshly switched session, @@ -287,7 +234,7 @@ struct MessageListView: View { rebuildSettledItems() Self.log.info("[MessageList.onLoadChange] post-rebuild settled=\(settledItems.count) sid=\(sid, privacy: .public)") readyTask?.cancel() - settleAtBottom(proxy: proxy, reason: "loadFinished") + requestScrollToBottom() anchor.resetToBottom() // Fade-in lives on `readyTask` so the content-growth path // (`scrollToBottomDebounced`, which owns `scrollTask`) cannot cancel it. @@ -298,27 +245,20 @@ struct MessageListView: View { } } - private func handleStreamingChange(old: Bool, new: Bool, proxy: ScrollViewProxy) { + private func handleStreamingChange(old: Bool, new: Bool) { logScrollState("streamingChange", extra: "old=\(old) new=\(new) lastRole=\(chatBridge.messages.last?.role.rawValue ?? "")") - if !old && new, - let activeTurnUserMessageID, - chatBridge.messages.last?.id == activeTurnUserMessageID, - chatBridge.messages.last?.role == .user - { - pendingIndicatorSpacerReduction = Self.streamingIndicatorEstimatedHeight - updateActiveTurnMaxMeasuredHeight() - if repinActiveTurnIfNeeded(proxy: proxy) { return } - } // Only react when streaming ends — the settled list doesn't change at start. - guard old && !new else { return } + guard old && !new else { + requestScrollToBottom() + return + } pendingIndicatorSpacerReduction = 0 rebuildSettledItems() - if repinActiveTurnIfNeeded(proxy: proxy) { return } anchor.resetToBottom() // The just-finished turn moves out of `StreamingMessageView` and into // the settled list. That row handoff makes the scroll content reload and can // momentarily snap the offset; re-assert the bottom across the handoff. - settleAtBottom(proxy: proxy, reason: "streamingEnded") + requestScrollToBottom() } // MARK: - Helpers @@ -328,6 +268,55 @@ struct MessageListView: View { ChatMessageListView(messages: Array(messages)) } + private var transcriptItems: [ChatTranscriptListItem] { + var items: [ChatTranscriptListItem] = [ + .accessory(.init(id: "desktop-top-padding", kind: .topPadding)), + ] + items += ChatTranscriptListItem.items(for: settledItems) + + if !windowState.focusMode { + let activeMessages = activeResponseMessages(from: chatBridge.messages) + let transientMinSize = activeMessages.contains(where: \.isStreaming) ? 1 : 2 + items += ChatTranscriptListItem.items(for: activeMessages, transientGroupMinSize: transientMinSize) + } + + if shouldShowEndLoadingIndicator { + items.append(.accessory(.init(id: "desktop-end-loading-indicator", kind: .loadingNext))) + } + + if !chatBridge.isStreaming && !settledItems.isEmpty { + items.append(.accessory(.init(id: "desktop-web-preview", kind: .webPreview))) + } + return items + } + + private var shouldShowEndLoadingIndicator: Bool { + (chatBridge.isLoadingFromDisk && chatBridge.messages.isEmpty) + || (chatBridge.isStreaming && !chatBridge.hasPendingPlanDecision) + } + + @ViewBuilder + private func transcriptAccessory(_ accessory: ChatTranscriptAccessory) -> some View { + switch accessory.kind { + case .topPadding: + Color.clear.frame(height: 16) + case .loadingNext, .streamingIndicator: + HStack(alignment: .top, spacing: 0) { + StreamingIndicatorView( + isThinking: chatBridge.isThinking, + startDate: chatBridge.streamingStartDate, + agentProvider: chatBridge.agentProvider, + outputTokens: chatBridge.liveOutputTokens + ) + Spacer(minLength: 40) + } + case .webPreview: + WebPreviewButton(messages: settledItems) + case .loadingPrevious, .custom: + EmptyView() + } + } + // MARK: - Settled Items private func rebuildSettledItems() { @@ -337,21 +326,14 @@ struct MessageListView: View { withTransaction(t) { settledItems = messages } } - private func handleLastMessageChange(proxy: ScrollViewProxy) { + private func handleLastMessageChange() { guard isSessionReady, !chatBridge.isLoadingFromDisk else { return } rebuildSettledItems() guard let last = chatBridge.messages.last else { return } logScrollState("lastMessageChange", extra: "lastRole=\(last.role.rawValue) lastID=\(last.id.uuidString)") - if last.role == .user { - startActiveTurn(last.id) - pendingIndicatorSpacerReduction = chatBridge.isStreaming ? Self.streamingIndicatorEstimatedHeight : 0 - updateActiveTurnMaxMeasuredHeight() - isPinningLatestTurnToTop = true - canReleasePinnedTurnByScroll = false - pinSentMessageToTop(last.id, proxy: proxy, animated: true) - return + if last.role != .user { + requestScrollToBottom() } - if repinActiveTurnIfNeeded(proxy: proxy) { return } } private func prepareForNewUserSend() { @@ -360,6 +342,7 @@ struct MessageListView: View { scrollTask = nil settleScrollTask?.cancel() pinToTopTask?.cancel() + scrollRequestTask?.cancel() activeTurnUserMessageID = nil isPinningLatestTurnToTop = false canReleasePinnedTurnByScroll = false @@ -367,6 +350,23 @@ struct MessageListView: View { activeTurnMaxMeasuredHeight = 0 } + private func requestScrollToBottom() { + scrollRequestTask?.cancel() + shouldScrollToBottom = false + scrollRequestTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(10)) + guard !Task.isCancelled else { return } + shouldScrollToBottom = true + } + } + + /// Returns the last consecutive assistant sequence (including streaming turn) while streaming. + /// Returns an empty array when not streaming so no separate streaming rows render. + private func activeResponseMessages(from messages: [ChatMessage]) -> [ChatMessage] { + guard messages.last?.isStreaming == true else { return [] } + return chatSuppressPlanReadyFollowups(in: Array(messages[chatStreamingBoundaryIndex(in: messages)...])) + } + /// If streaming, returns only completed messages excluding the last consecutive (non-error) assistant sequence. /// If not streaming, returns all messages without the streaming flag. /// In focus mode, further filters to only user messages and completed assistant responses. @@ -433,8 +433,8 @@ struct MessageListView: View { } /// Coalesced scroll-to-bottom for content growth. Streaming can update the - /// geometry once per token; keep bottom-follow to at most one scroll every - /// couple of seconds so it doesn't fight SwiftUI's lazy layout. + /// geometry once per token; keep bottom-follow to at most one scroll per + /// second so it doesn't fight SwiftUI's lazy layout. private func scrollToBottomDebounced(_ proxy: ScrollViewProxy) { guard scrollTask == nil else { return } logScrollState("scrollToBottom.debounceScheduled") diff --git a/Packages/Tests/MessageListTests/MessageListPinningControllerTests.swift b/Packages/Tests/MessageListTests/MessageListPinningControllerTests.swift new file mode 100644 index 00000000..1d26a5d7 --- /dev/null +++ b/Packages/Tests/MessageListTests/MessageListPinningControllerTests.swift @@ -0,0 +1,108 @@ +import Foundation +import Testing +@testable import MessageList + +@Suite("MessageList pinning controller") +struct MessageListPinningControllerTests { + @Test("User message followed by ten non-streaming responses scrolls to bottom") + func userMessageFollowedByTenResponsesScrollsBottom() { + var controller = MessageListPinningController() + let userID = UUID() + + let pinAction = controller.handleLastMessageChange( + id: userID, + isUserMessage: true, + isStreaming: false, + isAtBottom: false + ) + #expect(pinAction == .pinUserMessageToTop(userID)) + #expect(controller.isPinningUserMessage) + + for index in 0..<10 { + let responseAction = controller.handleLastMessageChange( + id: UUID(), + isUserMessage: false, + isStreaming: false, + isAtBottom: true + ) + #expect( + responseAction == (index == 0 ? .releasePinAndScrollToBottom : .scrollToBottom), + "response \(index + 1) should keep the transcript moving to the bottom" + ) + } + + #expect(!controller.isPinningUserMessage) + } + + @Test("Streaming responses keep the user message pinned until streaming ends") + func streamingResponsesKeepPinUntilStreamEnds() { + var controller = MessageListPinningController() + let userID = UUID() + + _ = controller.handleLastMessageChange( + id: userID, + isUserMessage: true, + isStreaming: false, + isAtBottom: true + ) + + for _ in 0..<10 { + let action = controller.handleLastMessageChange( + id: UUID(), + isUserMessage: false, + isStreaming: true, + isAtBottom: true + ) + #expect(action == .repinUserMessageToTop(userID)) + #expect(controller.isPinningUserMessage) + } + + let endAction = controller.handleStreamingChange(oldValue: true, newValue: false, isAtBottom: true) + #expect(endAction == .releasePinAndScrollToBottom) + #expect(!controller.isPinningUserMessage) + } + + @Test("Assistant message while scrolled up does not scroll to bottom") + func assistantMessageWhileScrolledUpDoesNotScrollBottom() { + var controller = MessageListPinningController() + + let action = controller.handleLastMessageChange( + id: UUID(), + isUserMessage: false, + isStreaming: false, + isAtBottom: false + ) + + #expect(action == .none) + } + + @Test("Assistant message while at bottom scrolls to bottom") + func assistantMessageWhileAtBottomScrollsBottom() { + var controller = MessageListPinningController() + + let action = controller.handleLastMessageChange( + id: UUID(), + isUserMessage: false, + isStreaming: false, + isAtBottom: true + ) + + #expect(action == .scrollToBottom) + } + + @Test("User message pins even when scrolled up") + func userMessagePinsEvenWhenScrolledUp() { + var controller = MessageListPinningController() + let userID = UUID() + + let action = controller.handleLastMessageChange( + id: userID, + isUserMessage: true, + isStreaming: false, + isAtBottom: false + ) + + #expect(action == .pinUserMessageToTop(userID)) + #expect(controller.isPinningUserMessage) + } +} diff --git a/Packages/Tests/MessageListTests/MessageListPreviewTests.swift b/Packages/Tests/MessageListTests/MessageListPreviewTests.swift new file mode 100644 index 00000000..332e2ed9 --- /dev/null +++ b/Packages/Tests/MessageListTests/MessageListPreviewTests.swift @@ -0,0 +1,107 @@ +#if DEBUG +import SwiftUI +import Testing +import ViewInspector +@testable import MessageList + +@MainActor +@Suite("MessageList preview") +struct MessageListPreviewTests { + @Test("Sending a user message followed by assistant messages renders the last assistant response") + func sendingUserMessageFollowedByAssistantMessagesRendersLastAssistantResponse() throws { + let model = MessageListSendModel() + let view = MessageListSendHarness(model: model) + + try view.inspect().find(button: "Send user").tap() + try view.inspect().find(button: "Send 1").tap() + try view.inspect().find(button: "Send 2").tap() + try view.inspect().find(button: "Send 3").tap() + try view.inspect().find(button: "Send 4").tap() + + let inspected = try view.inspect() + let texts = inspected.findAll(ViewType.Text.self).compactMap { try? $0.string() } + + #expect(texts.contains("4")) + } + + @Test("Incoming assistant messages while scrolled up keep the bottom binding false") + func incomingAssistantMessagesWhileScrolledUpKeepBottomBindingFalse() throws { + let model = MessageListSendModel() + let view = MessageListSendHarness(model: model) + + try view.inspect().find(button: "Scroll up").tap() + try view.inspect().find(button: "Send 1").tap() + try view.inspect().find(button: "Send 2").tap() + try view.inspect().find(button: "Send 3").tap() + try view.inspect().find(button: "Send 4").tap() + + let inspected = try view.inspect() + let texts = inspected.findAll(ViewType.Text.self).compactMap { try? $0.string() } + + #expect(texts.contains("4")) + #expect(!model.isAtBottom) + #expect(!model.shouldScrollToBottom) + } +} + +private final class MessageListSendModel: ObservableObject { + @Published var messages: [MessageListPreviewMessage] = [] + @Published var isAtBottom = true + @Published var shouldScrollToBottom = false + + func sendUserMessage() { + messages.append(.init(text: "user", isUserMessage: true)) + requestScrollToBottom() + } + + func sendAssistantMessage(_ text: String) { + let wasAtBottom = isAtBottom + messages.append(.init(text: text, isUserMessage: false)) + if wasAtBottom { + requestScrollToBottom() + } + } + + func setScrolledUp() { + isAtBottom = false + shouldScrollToBottom = false + } + + private func requestScrollToBottom() { + shouldScrollToBottom = true + } +} + +private struct MessageListSendHarness: View { + @ObservedObject var model: MessageListSendModel + + var body: some View { + VStack { + Button("Send user") { + model.sendUserMessage() + } + + Button("Scroll up") { + model.setScrolledUp() + } + + ForEach(["1", "2", "3", "4"], id: \.self) { text in + Button("Send \(text)") { + model.sendAssistantMessage(text) + } + } + + MessageList( + messages: model.messages, + shouldScrollToBottom: model.shouldScrollToBottom, + isAtBottom: Binding( + get: { model.isAtBottom }, + set: { model.isAtBottom = $0 } + ) + ) { message in + MessageListPreviewRow(message: message) + } + } + } +} +#endif diff --git a/Packages/Tests/MessageListTests/MessageListScrollAnchorTests.swift b/Packages/Tests/MessageListTests/MessageListScrollAnchorTests.swift new file mode 100644 index 00000000..5c050321 --- /dev/null +++ b/Packages/Tests/MessageListTests/MessageListScrollAnchorTests.swift @@ -0,0 +1,41 @@ +import CoreGraphics +import Testing +@testable import MessageList + +@Suite("MessageListScrollAnchor") +struct MessageListScrollAnchorTests { + @Test("Content growth while anchored requests bottom scroll") + func contentGrowthWhileAnchoredRequestsBottomScroll() { + var anchor = MessageListScrollAnchor(threshold: 120) + _ = anchor.apply(contentHeight: 1000, visibleMaxY: 1000) + + let decision = anchor.apply(contentHeight: 1400, visibleMaxY: 1000) + + #expect(decision == .scrollToBottom) + #expect(anchor.isNearBottom) + } + + @Test("Content growth while scrolled up does not re-anchor") + func contentGrowthWhileScrolledUpDoesNotReanchor() { + var anchor = MessageListScrollAnchor(threshold: 120) + _ = anchor.apply(contentHeight: 1000, visibleMaxY: 1000) + _ = anchor.apply(contentHeight: 1000, visibleMaxY: 600) + + let decision = anchor.apply(contentHeight: 1400, visibleMaxY: 600) + + #expect(decision == .none) + #expect(!anchor.isNearBottom) + } + + @Test("Reset restores bottom anchoring") + func resetRestoresBottomAnchoring() { + var anchor = MessageListScrollAnchor(threshold: 120) + _ = anchor.apply(contentHeight: 1000, visibleMaxY: 1000) + _ = anchor.apply(contentHeight: 1000, visibleMaxY: 500) + #expect(!anchor.isNearBottom) + + anchor.resetToBottom() + + #expect(anchor.isNearBottom) + } +} diff --git a/RxCode/App/AppState+CrossProject.swift b/RxCode/App/AppState+CrossProject.swift index 16cd3bb4..d52be87d 100644 --- a/RxCode/App/AppState+CrossProject.swift +++ b/RxCode/App/AppState+CrossProject.swift @@ -582,7 +582,7 @@ extension AppState { return false } if hasToolUse { - flushPendingUpdates(for: sessionKey) + flushPendingUpdates(for: sessionKey, forceText: true) } updateState(sessionKey) { state in diff --git a/RxCode/App/AppState+Messaging.swift b/RxCode/App/AppState+Messaging.swift index 871bf680..08d6bc78 100644 --- a/RxCode/App/AppState+Messaging.swift +++ b/RxCode/App/AppState+Messaging.swift @@ -423,7 +423,7 @@ extension AppState { for sessionKey: String, extraMutations: ((inout SessionStreamState) -> Void)? = nil ) { - flushPendingUpdates(for: sessionKey) + flushPendingUpdates(for: sessionKey, forceText: true) updateState(sessionKey) { state in state.flushTask?.cancel() state.flushTask = nil diff --git a/RxCode/App/AppState+Project.swift b/RxCode/App/AppState+Project.swift index 363123bc..4492f194 100644 --- a/RxCode/App/AppState+Project.swift +++ b/RxCode/App/AppState+Project.swift @@ -147,7 +147,7 @@ extension AppState { } if sessionStates[session.id]?.isStreaming == true { - flushPendingUpdates(for: session.id) + flushPendingUpdates(for: session.id, forceText: true) } updateState(session.id) { $0.hasUncheckedCompletion = false } diff --git a/RxCode/App/AppState+Stream.swift b/RxCode/App/AppState+Stream.swift index dca6b1d5..a1b8d46c 100644 --- a/RxCode/App/AppState+Stream.swift +++ b/RxCode/App/AppState+Stream.swift @@ -6,7 +6,7 @@ import RxCodeSync import SwiftUI extension AppState { - // MARK: - Text Delta Throttle (50ms) + // MARK: - Text Delta Grouping func startFlushTimer(for sessionKey: String) { stopFlushTimer(for: sessionKey) @@ -26,13 +26,27 @@ extension AppState { sessionStates[sessionKey]?.flushTask = nil } - func flushPendingUpdates(for key: String) { + func flushPendingUpdates(for key: String, forceText: Bool = false) { guard var state = sessionStates[key] else { return } let hasText = !state.textDeltaBuffer.isEmpty let hasToolResults = !state.pendingToolResults.isEmpty + let textGroup: String? + if hasText { + if forceText || hasToolResults { + textGroup = state.textDeltaBuffer + state.textDeltaBuffer = "" + } else if let group = Self.nextStreamingTextGroup(from: state.textDeltaBuffer) { + textGroup = group.text + state.textDeltaBuffer = group.remainder + } else { + textGroup = nil + } + } else { + textGroup = nil + } - guard hasText || hasToolResults else { return } + guard textGroup != nil || hasToolResults else { return } let prevMessages = state.messages @@ -95,9 +109,7 @@ extension AppState { } // 2. Text delta flush - if hasText { - let buffered = state.textDeltaBuffer - state.textDeltaBuffer = "" + if let buffered = textGroup, !buffered.isEmpty { if let idx = lastStreamingAssistantIdx() { if state.needsNewMessage { // New Claude turn after receiving tool result — start a new ChatMessage @@ -118,6 +130,41 @@ extension AppState { broadcastMobileMessageDiff(sessionKey: key, prev: prevMessages, next: state.messages, isStreaming: state.isStreaming) } + private static let streamingTextGroupTokenCount = 10 + + private static func nextStreamingTextGroup(from text: String) -> (text: String, remainder: String)? { + var tokenCount = 0 + var isInsideToken = false + var index = text.startIndex + + while index < text.endIndex { + let character = text[index] + if character.isWhitespace { + if isInsideToken { + isInsideToken = false + if tokenCount >= streamingTextGroupTokenCount { + var splitIndex = text.index(after: index) + while splitIndex < text.endIndex, text[splitIndex].isWhitespace { + splitIndex = text.index(after: splitIndex) + } + return (String(text[.. streamingTextGroupTokenCount { + return (String(text[..= streamingTextGroupTokenCount else { return nil } + return (text, "") + } + // MARK: - Stream Event Handler func handlePartialEvent(_ raw: String, for sessionKey: String) { @@ -145,7 +192,7 @@ extension AppState { let name = contentBlock["name"] as? String else { return } let toolCall = ToolCall(id: id, name: name, input: [:]) // Flush the text buffer first so text blocks are committed before tools - flushPendingUpdates(for: sessionKey) + flushPendingUpdates(for: sessionKey, forceText: true) updateState(sessionKey) { state in state.isThinking = false // needsNewMessage: new Claude turn after tool result — create a new ChatMessage @@ -242,7 +289,7 @@ extension AppState { func detachCurrentStream(in window: WindowState) { let key = window.currentSessionId ?? window.newSessionKey - flushPendingUpdates(for: key) + flushPendingUpdates(for: key, forceText: true) stopFlushTimer(for: key) } @@ -259,7 +306,7 @@ extension AppState { // nor the streaming view and vanishes until the next turn. Clearing // isStreaming here also stops processStream's end-of-stream cleanup // from re-finalizing the cancelled message while we suspend. - flushPendingUpdates(for: key) + flushPendingUpdates(for: key, forceText: true) stopFlushTimer(for: key) updateState(key) { state in diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index d01534f6..28d1a02e 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -34,7 +34,7 @@ struct SessionStreamState { var streamTask: Task? var hasUncheckedCompletion = false - // Text delta buffer (50ms throttle) + // Text delta buffer (10-token grouped flush) var textDeltaBuffer = "" var pendingToolResults: [(toolUseId: String, content: String, isError: Bool)] = [] var flushTask: Task? diff --git a/RxCodeMobile/Views/MobileChatView.swift b/RxCodeMobile/Views/MobileChatView.swift index 52012b3b..1387168d 100644 --- a/RxCodeMobile/Views/MobileChatView.swift +++ b/RxCodeMobile/Views/MobileChatView.swift @@ -97,6 +97,8 @@ struct MobileChatView: View { @State private var canReleasePinnedTurnByScroll = false @State private var distanceFromBottom: CGFloat = 0 @State private var lastAutoBottomScrollDate = Date.distantPast + @State private var packageShouldScrollToBottom = false + @State private var packageScrollRequestTask: Task? @State private var minimumThreadLoadElapsed = false @State private var isThreadLoadingOverlayVisible = true @State private var threadLoadingHideTask: Task? @@ -368,204 +370,99 @@ struct MobileChatView: View { private var activeThreadLayout: some View { ZStack(alignment: .bottom) { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 12) { - if isLoadingMore { - loadMoreSpinner - } - ChatMessageListView(messages: messages) - if isStreaming { - MobileStreamingIndicator(isThinking: isThinking) - .transition(.opacity) - } - Color.clear - .frame(height: 1) - .onGeometryChange(for: CGFloat.self) { proxy in - proxy.frame(in: .named(chatContentCoordinateSpace)).minY - } action: { newValue in - updateTailSpacerMinY(newValue) - } - Color.clear - .frame(height: 1) - .id(Self.endOfScreenAnchorID) - Color.clear - .frame(height: minTailSpacer) - // Extra tail spacer: pads the latest turn only while a - // freshly sent user message is pinned to the top, and - // shrinks as the assistant response grows. - Color.clear - .frame(height: pinTailSpacerExtraHeight) - Color.clear - .frame(height: 1) - .id(Self.bottomAnchorID) - } - .padding(.horizontal, 16) - .padding(.top, 8) - .opacity(isInitialMessageListHidden && !messages.isEmpty ? 0 : 1) - .animation(.easeInOut(duration: 0.2), value: isStreaming) - .animation(.easeInOut(duration: 0.2), value: isLoadingMore) - .animation(.easeInOut(duration: 0.18), value: isInitialMessageListHidden) - .coordinateSpace(.named(chatContentCoordinateSpace)) - .environment(\.chatTrackedMessageID, trackedUserMessageID) - .environment(\.chatTrackedMessageGeometry, updateLatestUserMinY) - .accessibilityElement(children: .contain) - .accessibilityIdentifier("chat-message-list") - } - .accessibilityIdentifier("chat-screen") - .mobileDismissesKeyboardOnScroll(.interactively) - .onGeometryChange(for: CGRect.self) { proxy in - proxy.frame(in: .global) - } action: { rect in - scrollViewHeight = rect.height - scrollViewMinY = rect.minY - } - .onScrollPhaseChange { _, newPhase, _ in - isUserDragging = newPhase == .tracking - || newPhase == .interacting - || newPhase == .decelerating - } - .onScrollGeometryChange(for: CGFloat.self) { geo in - geo.contentSize.height - geo.visibleRect.maxY - } action: { _, distanceFromBottom in - self.distanceFromBottom = distanceFromBottom - let near = distanceFromBottom <= Self.nearBottomThreshold - if isNearBottom != near { - mobileChatLogger.debug( - "[ScrollButton] visibility near=\(near, privacy: .public) distance=\(Double(distanceFromBottom), privacy: .public)" - ) - isNearBottom = near - } - // Returning to the true bottom re-arms auto-follow. - if distanceFromBottom <= Self.atBottomThreshold { - autoScrollEnabled = true - if isUserDragging { - isPinningLatestTurnToTop = false - } - } - } - .onScrollGeometryChange(for: CGFloat.self) { geo in - geo.contentOffset.y - } action: { oldOffsetY, offsetY in - if isUserDragging, - isPinningLatestTurnToTop, - canReleasePinnedTurnByScroll, - offsetY > oldOffsetY + Self.userScrollUpDelta - { - releasePinnedTurnToBottom(proxy: proxy) - } - // A deliberate upward drag means the user wants to read - // history — stop the stream from pulling them back down. - // Gated on `isUserDragging` so keyboard/layout-induced - // offset shifts don't disable auto-follow. - if isUserDragging, offsetY < oldOffsetY - Self.userScrollUpDelta { - autoScrollEnabled = false - } - if isUserDragging, offsetY < Self.loadMoreThreshold { - triggerLoadMoreIfNeeded() - } - } - .onAppear { - // A cached thread already has its messages — jump straight - // to the newest one. Threads loaded async are handled by - // the `messages.last?.id` change below. - establishInitialScroll(proxy: proxy, reason: "onAppear") - } - .task { - // Let the initial scroll-to-bottom settle before arming the - // scroll-up "load more" trigger, so opening a thread doesn't - // immediately page in older history. - try? await Task.sleep(for: .seconds(0.8)) - didSettleInitialScroll = true - } - .onChange(of: messages.last?.id) { _, _ in - // Keyed on the last message id so a prepended older page - // (which leaves the tail unchanged) doesn't yank the view. - guard let last = messages.last else { return } - if last.role == .user, awaitingSentUserMessage { - // The message we just sent round-tripped back from the - // desktop — pin it to the top of the viewport. Handle - // this before initial-scroll gating so the first - // message in an empty thread is pinned too. - awaitingSentUserMessage = false - autoScrollEnabled = false - activeTurnUserMessageID = last.id - isPinningLatestTurnToTop = true - initialScrollTask?.cancel() - isEstablishingInitialScroll = false - didEstablishInitialScroll = true - isInitialMessageListHidden = false - pinSentMessageToTop(last.id, proxy: proxy, animated: true) - } else if !didEstablishInitialScroll { - // First page of messages arrived after the view - // appeared — jump straight to the newest one. - establishInitialScroll(proxy: proxy, reason: "messagesArrived") - } else if repinActiveTurnIfNeeded(proxy: proxy) { - return - } else if autoScrollEnabled { - scrollToBottomDebounced(proxy: proxy, reason: "lastMessage") - } + ChatTranscriptList( + items: mobileTranscriptItems, + isStreaming: isStreaming, + shouldScrollToBottom: packageShouldScrollToBottom, + isAtBottom: $isNearBottom, + hasMorePrevious: { + didSettleInitialScroll + && state.hasMoreMessages(sessionID: sessionID) + && !state.isLoadingMoreMessages(sessionID: sessionID) + }, + loadMorePrevious: loadMorePreviousMessages, + rowPadding: EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16), + accessibilityIdentifier: "chat-message-list" + ) { accessory in + mobileTranscriptAccessory(accessory) + } + .opacity(isInitialMessageListHidden && !messages.isEmpty ? 0 : 1) + .animation(.easeInOut(duration: 0.2), value: isStreaming) + .animation(.easeInOut(duration: 0.2), value: isLoadingMore) + .animation(.easeInOut(duration: 0.18), value: isInitialMessageListHidden) + .mobileDismissesKeyboardOnScroll(.interactively) + .onGeometryChange(for: CGRect.self) { proxy in + proxy.frame(in: .global) + } action: { rect in + scrollViewHeight = rect.height + scrollViewMinY = rect.minY + } + .onAppear { + establishInitialPackageScroll(reason: "onAppear") + } + .task { + try? await Task.sleep(for: .seconds(0.8)) + didSettleInitialScroll = true + } + .onChange(of: isNearBottom) { _, nearBottom in + if nearBottom { + autoScrollEnabled = true + isPinningLatestTurnToTop = false + } else if didEstablishInitialScroll { + autoScrollEnabled = false } - .onChange(of: messages.last?.content) { _, _ in - guard didEstablishInitialScroll else { return } - if repinActiveTurnIfNeeded(proxy: proxy) { return } - guard autoScrollEnabled else { return } - scrollToBottomDebounced(proxy: proxy, reason: "messageContent") + } + .onChange(of: messages.last?.id) { _, _ in + guard let last = messages.last else { return } + if last.role == .user, awaitingSentUserMessage { + awaitingSentUserMessage = false + autoScrollEnabled = false + activeTurnUserMessageID = last.id + isPinningLatestTurnToTop = true + didEstablishInitialScroll = true + isInitialMessageListHidden = false + return } - .onChange(of: isStreaming) { _, streaming in - // Keep the newly appeared loading indicator in view. - if !streaming { - pendingIndicatorSpacerReduction = 0 - } - guard streaming, didEstablishInitialScroll else { return } - if activeTurnUserMessageID != nil { - pendingIndicatorSpacerReduction = Self.streamingIndicatorEstimatedHeight - } - if repinActiveTurnIfNeeded(proxy: proxy) { return } - guard autoScrollEnabled else { return } - scrollToBottomDebounced(proxy: proxy, reason: "streamingStarted") + if !didEstablishInitialScroll { + establishInitialPackageScroll(reason: "messagesArrived") + } else if autoScrollEnabled { + requestPackageScrollToBottom(reason: "lastMessage") } - .onChange(of: isLoadingMore) { _, loading in - guard !loading, let anchor = pendingTopAnchorID else { return } - pendingTopAnchorID = nil - if autoScrollEnabled { - // The page was pulled in to fill a near-empty screen - // on open — keep the newest message in view instead of - // jumping up to the freshly prepended history. - settleScroll(proxy: proxy, to: Self.bottomAnchorID, anchor: .bottom) - } else { - // The user scrolled up to read history: restore the - // viewport to the message that was on top so the - // prepend isn't jarring. - settleScroll(proxy: proxy, to: anchor, anchor: .top) - } - } - .onChange(of: composerMinY) { oldValue, newValue in - handleComposerBoundaryChange( - oldValue: oldValue, - newValue: newValue, - proxy: proxy - ) + } + .onChange(of: messages.last?.content) { _, _ in + guard didEstablishInitialScroll, autoScrollEnabled else { return } + requestPackageScrollToBottom(reason: "messageContent") + } + .onChange(of: isStreaming) { _, streaming in + if !streaming { + pendingIndicatorSpacerReduction = 0 + isPinningLatestTurnToTop = false } - .overlay(alignment: .bottomLeading) { - if !isNearBottom { - scrollToBottomButton(proxy: proxy) - .padding(.leading, 16) - .padding(.bottom, scrollToBottomButtonBottomPadding) - .transition(.scale.combined(with: .opacity)) - .zIndex(1) - .onAppear { - mobileChatLogger.debug( - "[ScrollButton] appeared bottomPadding=\(Double(scrollToBottomButtonBottomPadding), privacy: .public)" - ) - } - .onDisappear { - mobileChatLogger.debug("[ScrollButton] disappeared") - } - } + guard didEstablishInitialScroll, autoScrollEnabled else { return } + requestPackageScrollToBottom(reason: streaming ? "streamingStarted" : "streamingEnded") + } + .onChange(of: composerMinY) { oldValue, newValue in + guard didEstablishInitialScroll, abs(newValue - oldValue) > 0.5, autoScrollEnabled else { return } + requestPackageScrollToBottom(reason: "composer") + } + .overlay(alignment: .bottomLeading) { + if !isNearBottom { + scrollToBottomButton() + .padding(.leading, 16) + .padding(.bottom, scrollToBottomButtonBottomPadding) + .transition(.scale.combined(with: .opacity)) + .zIndex(1) + .onAppear { + mobileChatLogger.debug( + "[ScrollButton] appeared bottomPadding=\(Double(scrollToBottomButtonBottomPadding), privacy: .public)" + ) + } + .onDisappear { + mobileChatLogger.debug("[ScrollButton] disappeared") + } } - .animation(.spring(duration: 0.25), value: isNearBottom) } + .animation(.spring(duration: 0.25), value: isNearBottom) VStack(spacing: 0) { if !queuedMessages.isEmpty { @@ -612,6 +509,47 @@ struct MobileChatView: View { .zIndex(3) } } + .accessibilityIdentifier("chat-screen") + } + + private var mobileTranscriptItems: [ChatTranscriptListItem] { + var items: [ChatTranscriptListItem] = [ + .accessory(.init(id: "mobile-top-padding", kind: .topPadding)), + ] + if isLoadingMore { + items.append(.accessory(.init(id: "mobile-loading-previous", kind: .loadingPrevious))) + } + items += ChatTranscriptListItem.items(for: messages) + if shouldShowEndLoadingIndicator { + items.append(.accessory(.init(id: "mobile-end-loading-indicator", kind: .loadingNext))) + } + items.append(.accessory(.init(id: "mobile-bottom-spacer", kind: .custom))) + return items + } + + private var shouldShowEndLoadingIndicator: Bool { + isStreaming || (isThreadLoadingMessages && messages.isEmpty) + } + + @ViewBuilder + private func mobileTranscriptAccessory(_ accessory: ChatTranscriptAccessory) -> some View { + switch accessory.kind { + case .topPadding: + Color.clear.frame(height: 8) + case .loadingPrevious: + loadMoreSpinner + case .loadingNext, .streamingIndicator: + MobileStreamingIndicator(isThinking: isThinking) + .transition(.opacity) + case .webPreview: + EmptyView() + case .custom: + if accessory.id == "mobile-bottom-spacer" { + Color.clear.frame(height: minTailSpacer) + } else { + EmptyView() + } + } } private var messages: [ChatMessage] { @@ -780,6 +718,10 @@ struct MobileChatView: View { } } + private func loadMorePreviousMessages() async throws { + _ = await state.loadMoreMessages(sessionID: sessionID) + } + /// Scroll restoration after prepending older messages needs to survive lazy /// layout and row grouping changes. Repeating the same non-animated scroll /// across a handful of frames keeps the original anchor visible without @@ -807,6 +749,36 @@ struct MobileChatView: View { } } + private func establishInitialPackageScroll(reason: String) { + guard !didEstablishInitialScroll else { + isInitialMessageListHidden = false + return + } + guard !messages.isEmpty else { return } + didEstablishInitialScroll = true + isInitialMessageListHidden = true + requestPackageScrollToBottom(reason: reason) + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(180)) + withAnimation(.easeInOut(duration: 0.18)) { + isInitialMessageListHidden = false + } + } + } + + private func requestPackageScrollToBottom(reason: String) { + packageScrollRequestTask?.cancel() + packageShouldScrollToBottom = false + mobileChatLogger.debug( + "[MessageList] scrollToBottom requested reason=\(reason, privacy: .public) session=\(sessionID, privacy: .public)" + ) + packageScrollRequestTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(10)) + guard !Task.isCancelled else { return } + packageShouldScrollToBottom = true + } + } + /// Jump straight to the latest message the first time the thread's content /// is available, so an opened thread starts at the bottom instead of the /// top. The deferred scroll covers the lazy stack not having measured its @@ -1036,7 +1008,7 @@ struct MobileChatView: View { // MARK: - Scroll to bottom button - private func scrollToBottomButton(proxy: ScrollViewProxy) -> some View { + private func scrollToBottomButton() -> some View { Button { mobileChatLogger.info( "[ScrollButton] tap session=\(sessionID, privacy: .public) messages=\(messages.count, privacy: .public) distance=\(Double(distanceFromBottom), privacy: .public) padding=\(Double(scrollToBottomButtonBottomPadding), privacy: .public)" @@ -1045,7 +1017,8 @@ struct MobileChatView: View { isUserDragging = false autoBottomScrollTask?.cancel() autoBottomScrollTask = nil - scrollToBottomFromButton(proxy) + isPinningLatestTurnToTop = false + requestPackageScrollToBottom(reason: "button") } label: { Image(systemName: "arrow.down") .font(.system(size: 14, weight: .semibold)) diff --git a/RxCodeMobileUITests/Support/MobileAppRobot.swift b/RxCodeMobileUITests/Support/MobileAppRobot.swift index d9c86291..f6c2cc9a 100644 --- a/RxCodeMobileUITests/Support/MobileAppRobot.swift +++ b/RxCodeMobileUITests/Support/MobileAppRobot.swift @@ -36,7 +36,11 @@ struct MobileAppRobot { // MARK: - Chat var chatScreen: XCUIElement { element(id: "chat-screen") } - var chatMessageList: XCUIElement { element(id: "chat-message-list") } + var anyTranscriptMessage: XCUIElement { + app.staticTexts + .matching(NSPredicate(format: "label CONTAINS %@", "Assistant reply inside")) + .firstMatch + } // MARK: - Queries @@ -151,13 +155,8 @@ struct MobileAppRobot { file: file, line: line ) XCTAssertTrue( - chatMessageList.waitForExistence(timeout: timeout), - "Message list was not shown", - file: file, line: line - ) - XCTAssertGreaterThan( - chatMessageList.staticTexts.count, 0, - "Message list rendered no text", + anyTranscriptMessage.waitForExistence(timeout: timeout), + "Transcript message was not shown", file: file, line: line ) }