From da2bf55782e105869afca97e7a7fa16cd41cb82f Mon Sep 17 00:00:00 2001 From: Arturo Carretero Calvo <10163049+ArtCC@users.noreply.github.com> Date: Fri, 28 Aug 2026 07:41:39 +0200 Subject: [PATCH 1/2] Tighten agent streaming chunking and presentation handling - Add presentation-focused agent stream tests for reasoning order, chunk limits, and whitespace retries - Update agent chunking to emit reasoning before content and cap adaptive chunk sizes - Increase agent stream pacing delay and pause request timeout once final content is present - Publish agent text updates directly in the view model and remove buffered flushes for agent streams - Extend mock agent streaming to yield preset events while waiting for cancellation - Refresh changelog build number and update agent streaming specification notes --- CHANGELOG.md | 2 +- .../AgentStreamUseCasePresentationTests.swift | 111 ++++++++++++++++++ .../Chat/AgentStreamUseCaseTests.swift | 24 ++-- .../Chat/ChatViewModelTests+Agent.swift | 46 ++++++++ .../Mocks/MockAgentStreamUseCase.swift | 3 + .../AgentStreamUseCase+Chunking.swift | 43 ++++++- .../Chat/UseCases/AgentStreamUseCase.swift | 40 ++----- .../Chat/ViewModels/ChatViewModel+Agent.swift | 23 ++-- specs/agent-tool-calling.instructions.md | 9 +- specs/chat-visual-style.instructions.md | 6 +- 10 files changed, 248 insertions(+), 59 deletions(-) create mode 100644 openclient-llm-test/Features/Chat/AgentStreamUseCasePresentationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1207300..9dd13e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Contributions are welcome โ€” see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. -## [1.6.35-build-88] - 2026-08-27 +## [1.6.35-build-89] - 2026-08-28 ### Added diff --git a/openclient-llm-test/Features/Chat/AgentStreamUseCasePresentationTests.swift b/openclient-llm-test/Features/Chat/AgentStreamUseCasePresentationTests.swift new file mode 100644 index 0000000..4f700c0 --- /dev/null +++ b/openclient-llm-test/Features/Chat/AgentStreamUseCasePresentationTests.swift @@ -0,0 +1,111 @@ +// +// AgentStreamUseCasePresentationTests.swift +// openclient-llm +// +// Created by Arturo Carretero Calvo on 27/08/2026. +// Copyright ยฉ 2026 Arturo Carretero Calvo. All rights reserved. +// + +import XCTest +@testable import openclient_llm + +@MainActor +extension AgentStreamUseCaseTests { + func test_execute_responseWithReasoning_emitsReasoningBeforeContentWithoutLosingText() async throws { + // Given + mockRepository.agentCompletionResult = .success( + makePresentationResponse(content: "Final answer", reasoning: "Think first") + ) + + // When + var reasoning = "" + var content = "" + var didReceiveContent = false + var reasoningArrivedAfterContent = false + let stream = sut.execute( + messages: [ChatMessage(role: .user, content: "Hi")], + model: "gpt-4", + parameters: .default, + toolRegistry: toolRegistry + ) + for try await event in stream { + switch event { + case .reasoning(let text): + reasoning += text + reasoningArrivedAfterContent = reasoningArrivedAfterContent || didReceiveContent + case .token(let text): + content += text + didReceiveContent = true + default: + break + } + } + + // Then + XCTAssertEqual(reasoning, "Think first") + XCTAssertEqual(content, "Final answer") + XCTAssertFalse(reasoningArrivedAfterContent) + } + + func test_execute_extremelyLongFinalResponse_limitsPresentationUpdates() async throws { + // Given + let content = String(repeating: "a", count: 100_000) + mockRepository.agentCompletionResult = .success(makePresentationResponse(content: content)) + + // When + var tokens: [String] = [] + let stream = sut.execute( + messages: [ChatMessage(role: .user, content: "Hi")], + model: "gpt-4", + parameters: .default, + toolRegistry: toolRegistry + ) + for try await event in stream { + if case .token(let text) = event { tokens.append(text) } + } + + // Then + XCTAssertEqual(tokens.joined(), content) + XCTAssertLessThanOrEqual(tokens.count, 300) + } + + func test_execute_whitespaceOnlyResponse_retriesWithoutEmittingBlankContent() async throws { + // Given + let repository = RecordingAgentRepository(responses: [ + makePresentationResponse(content: " \n\t "), + makePresentationResponse(content: "Final answer") + ]) + let whitespaceSUT = AgentStreamUseCase(repository: repository, chunkDelay: .zero) + + // When + var tokens: [String] = [] + let stream = whitespaceSUT.execute( + messages: [ChatMessage(role: .user, content: "Hi")], + model: "gpt-4", + parameters: .default, + toolRegistry: ToolRegistry(tools: [GetCurrentDatetimeTool()]) + ) + for try await event in stream { + if case .token(let text) = event { tokens.append(text) } + } + + // Then + XCTAssertEqual(tokens.joined(), "Final answer") + XCTAssertEqual(repository.requests.count, 2) + XCTAssertFalse(repository.toolRequests[0]?.isEmpty ?? true) + XCTAssertNil(repository.toolRequests[1]) + } +} + +private extension AgentStreamUseCaseTests { + func makePresentationResponse(content: String, reasoning: String? = nil) -> ChatCompletionResponse { + let message = ChatCompletionResponse.Message( + role: "assistant", content: content, reasoningContent: reasoning, images: nil, toolCalls: nil + ) + return ChatCompletionResponse( + id: "presentation-response", + choices: [ChatCompletionResponse.Choice(message: message, finishReason: "stop")], + usage: nil + ) + } +} diff --git a/openclient-llm-test/Features/Chat/AgentStreamUseCaseTests.swift b/openclient-llm-test/Features/Chat/AgentStreamUseCaseTests.swift index 728cb11..e70dc98 100644 --- a/openclient-llm-test/Features/Chat/AgentStreamUseCaseTests.swift +++ b/openclient-llm-test/Features/Chat/AgentStreamUseCaseTests.swift @@ -53,11 +53,12 @@ final class AgentStreamUseCaseTests: XCTestCase { // Then โ€” content emitted as chunked tokens (simulated typewriter streaming) XCTAssertEqual(tokens.joined(), "Hello world") + XCTAssertEqual(tokens, ["Hell", "o wo", "rld"]) } func test_execute_longFinalResponse_emitsBoundedChunksWithoutLosingContent() async throws { // Given - let content = String(repeating: "๐Ÿ™‚", count: 65) + String(repeating: "a", count: 256) + let content = String(repeating: "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ", count: 65) + String(repeating: "e\u{301}", count: 256) mockRepository.agentCompletionResult = .success(makeStopResponse(content: content)) // When @@ -74,11 +75,11 @@ final class AgentStreamUseCaseTests: XCTestCase { // Then XCTAssertEqual(tokens.joined(), content) - XCTAssertEqual(tokens.count, 11) - XCTAssertTrue(tokens.allSatisfy { $0.count <= 32 }) + XCTAssertEqual(tokens.count, 81) + XCTAssertTrue(tokens.allSatisfy { $0.count <= 4 }) } - func test_execute_veryLongFinalResponse_limitsAdaptiveChunkCount() async throws { + func test_execute_veryLongFinalResponse_capsAdaptiveChunkSize() async throws { // Given let content = String(repeating: "a", count: 10_000) mockRepository.agentCompletionResult = .success(makeStopResponse(content: content)) @@ -97,7 +98,8 @@ final class AgentStreamUseCaseTests: XCTestCase { // Then XCTAssertEqual(tokens.joined(), content) - XCTAssertLessThanOrEqual(tokens.count, 100) + XCTAssertEqual(tokens.count, 209) + XCTAssertTrue(tokens.allSatisfy { $0.count <= 48 }) } // MARK: - Tests โ€” Tool call round @@ -150,7 +152,7 @@ final class AgentStreamUseCaseTests: XCTestCase { // without tools, which forces the model to give a natural response. let firstResponse = makeStopResponse(content: "{}") let secondResponse = makeStopResponse(content: "Hola! Estoy bien, gracias.") - let seqRepo = makeSequentialRepo(responses: [firstResponse, secondResponse]) + let seqRepo = RecordingAgentRepository(responses: [firstResponse, secondResponse]) let seqSut = AgentStreamUseCase(repository: seqRepo) var tokens: [String] = [] @@ -158,7 +160,7 @@ final class AgentStreamUseCaseTests: XCTestCase { messages: [ChatMessage(role: .user, content: "Hola! Que tal?")], model: "ollama/qwen3:14b", parameters: .default, - toolRegistry: ToolRegistry(tools: []) + toolRegistry: ToolRegistry(tools: [GetCurrentDatetimeTool()]) ) for try await event in stream { if case .token(let text) = event { tokens.append(text) } @@ -166,7 +168,9 @@ final class AgentStreamUseCaseTests: XCTestCase { XCTAssertFalse(tokens.contains("{}"), "Raw '{}' must never reach the UI") XCTAssertEqual(tokens.joined(), "Hola! Estoy bien, gracias.") - XCTAssertEqual(seqRepo.callIndex, 2, "Should have made a second request without tools") + XCTAssertEqual(seqRepo.requests.count, 2, "Should have made a second request without tools") + XCTAssertFalse(seqRepo.toolRequests[0]?.isEmpty ?? true) + XCTAssertNil(seqRepo.toolRequests[1]) } func test_execute_toolCallsWithStopFinishReason_executesToolsInsteadOfEmittingContent() async throws { @@ -364,9 +368,9 @@ final class AgentStreamUseCaseTests: XCTestCase { // MARK: - Helpers private extension AgentStreamUseCaseTests { - func makeStopResponse(content: String) -> ChatCompletionResponse { + func makeStopResponse(content: String, reasoning: String? = nil) -> ChatCompletionResponse { let message = ChatCompletionResponse.Message( - role: "assistant", content: content, reasoningContent: nil, images: nil, toolCalls: nil + role: "assistant", content: content, reasoningContent: reasoning, images: nil, toolCalls: nil ) return ChatCompletionResponse( id: "resp-stop", diff --git a/openclient-llm-test/Features/Chat/ChatViewModelTests+Agent.swift b/openclient-llm-test/Features/Chat/ChatViewModelTests+Agent.swift index da986dc..578bf20 100644 --- a/openclient-llm-test/Features/Chat/ChatViewModelTests+Agent.swift +++ b/openclient-llm-test/Features/Chat/ChatViewModelTests+Agent.swift @@ -122,6 +122,52 @@ extension ChatViewModelTests { XCTAssertEqual(lastAssistant?.content, "Agent answer") } + func test_sendMessage_agentToken_publishesDirectlyWithoutServerStreamingBuffer() async throws { + // Given + let mockAgent = MockAgentStreamUseCase() + mockAgent.events = [.token("Agent")] + mockAgent.waitsForCancellation = true + let modelWithFunctionCalling = LLMModel(id: "gpt-4", capabilities: [.functionCalling]) + mockFetchModels.result = .success([modelWithFunctionCalling]) + let sutWithAgent = ChatViewModel( + fetchModelsUseCase: mockFetchModels, + streamMessageUseCase: mockStreamMessage, + agentStreamUseCase: mockAgent, + webSearchUseCase: mockWebSearch, + saveConversationUseCase: mockSaveConversation, + exportConversationUseCase: mockExportConversation, + branchConversationUseCase: mockBranchConversation, + getChatPreferencesUseCase: mockGetChatPreferences, + fetchMCPToolsUseCase: MockFetchMCPToolsUseCase(), + getConversationStartersUseCase: mockGetConversationStarters, + streamingBackgroundUseCase: MockStreamingBackgroundUseCase(), + notifyStreamingCompletedUseCase: MockNotifyStreamingCompletedUseCase() + ) + sutWithAgent.send(.viewAppeared) + await waitUntil { + if case .loaded = sutWithAgent.state { return true } + return false + } + + // When + sutWithAgent.send(.inputChanged("Tell me a story")) + sutWithAgent.send(.sendTapped) + await waitUntil { + guard case .loaded(let loadedState) = sutWithAgent.state else { return false } + return loadedState.messages.last?.content == "Agent" + } + + // Then + guard case .loaded(let loadedState) = sutWithAgent.state else { + XCTFail("Expected loaded state") + return + } + XCTAssertEqual(loadedState.streamingRevision, 1) + XCTAssertTrue(sutWithAgent.streamingUpdateBuffer.updates.isEmpty) + XCTAssertNil(sutWithAgent.streamingUpdateBuffer.flushTask) + sutWithAgent.send(.stopStreamingTapped) + } + func test_sendMessage_noCapabilitiesModel_andWebSearch_usesRegularStreaming() async throws { // Given โ€” model without any capabilities (no functionCalling) let mockAgent = MockAgentStreamUseCase() diff --git a/openclient-llm-test/Mocks/MockAgentStreamUseCase.swift b/openclient-llm-test/Mocks/MockAgentStreamUseCase.swift index d3230cf..07a6392 100644 --- a/openclient-llm-test/Mocks/MockAgentStreamUseCase.swift +++ b/openclient-llm-test/Mocks/MockAgentStreamUseCase.swift @@ -41,6 +41,9 @@ final class MockAgentStreamUseCase: AgentStreamUseCaseProtocol, @unchecked Senda let error = error return AsyncThrowingStream { continuation in if waitsForCancellation { + for event in events { + continuation.yield(event) + } activeContinuation = continuation continuation.onTermination = { [weak self] _ in Task { @MainActor in diff --git a/openclient-llm/Shared/Features/Chat/UseCases/AgentStreamUseCase+Chunking.swift b/openclient-llm/Shared/Features/Chat/UseCases/AgentStreamUseCase+Chunking.swift index 0233ed0..ddd2a81 100644 --- a/openclient-llm/Shared/Features/Chat/UseCases/AgentStreamUseCase+Chunking.swift +++ b/openclient-llm/Shared/Features/Chat/UseCases/AgentStreamUseCase+Chunking.swift @@ -9,14 +9,53 @@ import Foundation nonisolated extension AgentStreamUseCase { + func handleFinalChoice( + _ choice: ChatCompletionResponse.Choice, + continuation: AsyncThrowingStream.Continuation, + delay: Duration + ) async throws -> Bool { + let content = choice.message.content ?? "" + let reasoning = choice.message.reasoningContent + guard hasPresentableFinalContent(choice) else { return true } + if let reasoning, !reasoning.isEmpty { + try await yieldChunked( + reasoning, + as: { .reasoning($0) }, + continuation: continuation, + delay: delay + ) + } + if !content.isEmpty { + try await yieldChunked( + content, + as: { .token($0) }, + continuation: continuation, + delay: delay + ) + } + return false + } + + func hasPresentableFinalContent(_ choice: ChatCompletionResponse.Choice) -> Bool { + let content = choice.message.content?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let reasoning = choice.message.reasoningContent?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return content != "{}" && (!content.isEmpty || !reasoning.isEmpty) + } + func yieldChunked( _ text: String, as event: @Sendable (String) -> AgentEvent, continuation: AsyncThrowingStream.Continuation, delay: Duration ) async throws { - let maximumChunkCount = 100 - let chunkSize = max(32, (text.count + maximumChunkCount - 1) / maximumChunkCount) + let targetChunkCount = 100 + let maximumChunkCount = 300 + let minimumChunkSize = 4 + let preferredMaximumChunkSize = 48 + let adaptiveChunkSize = text.count / targetChunkCount + (text.count % targetChunkCount == 0 ? 0 : 1) + let preferredChunkSize = min(preferredMaximumChunkSize, max(minimumChunkSize, adaptiveChunkSize)) + let minimumSizeForChunkLimit = text.count / maximumChunkCount + (text.count % maximumChunkCount == 0 ? 0 : 1) + let chunkSize = max(preferredChunkSize, minimumSizeForChunkLimit) var index = text.startIndex while index < text.endIndex { try Task.checkCancellation() diff --git a/openclient-llm/Shared/Features/Chat/UseCases/AgentStreamUseCase.swift b/openclient-llm/Shared/Features/Chat/UseCases/AgentStreamUseCase.swift index 9869863..7f5b95c 100644 --- a/openclient-llm/Shared/Features/Chat/UseCases/AgentStreamUseCase.swift +++ b/openclient-llm/Shared/Features/Chat/UseCases/AgentStreamUseCase.swift @@ -83,7 +83,7 @@ struct AgentStreamUseCase: AgentStreamUseCaseProtocol { init( repository: ChatRepositoryProtocol = ChatRepository(), timeout: Duration = .seconds(300), - chunkDelay: Duration = .milliseconds(10) + chunkDelay: Duration = .milliseconds(20) ) { self.repository = repository self.timeout = timeout @@ -175,8 +175,12 @@ private extension AgentStreamUseCase { let response = try await request(context: context, messages: requestMessages, tools: tools) aggregateUsage = emitUsage(response.usage, aggregate: aggregateUsage, continuation: context.continuation) guard let choice = response.choices.first else { throw AgentStreamError.invalidResponse } + let toolCalls = choice.message.toolCalls ?? [] + if toolCalls.isEmpty, hasPresentableFinalContent(choice) { + await context.timeoutController.pause() + } - if let toolCalls = choice.message.toolCalls, !toolCalls.isEmpty { + if !toolCalls.isEmpty { guard !forceFinalResponse else { throw AgentStreamError.iterationLimitReached } forceFinalResponse = try await completeToolRound( choice: choice, @@ -185,7 +189,11 @@ private extension AgentStreamUseCase { toolCallCount: &toolCallCount, context: AgentRoundContext(requestMessages: requestMessages, loop: context) ) - } else if try await handleFinalChoice(choice, continuation: context.continuation) { + } else if try await handleFinalChoice( + choice, + continuation: context.continuation, + delay: chunkDelay + ) { guard !forceFinalResponse else { throw AgentStreamError.invalidResponse } forceFinalResponse = true } else { @@ -405,32 +413,6 @@ private extension AgentStreamUseCase { ) } - func handleFinalChoice( - _ choice: ChatCompletionResponse.Choice, - continuation: AsyncThrowingStream.Continuation - ) async throws -> Bool { - let content = choice.message.content ?? "" - let reasoning = choice.message.reasoningContent - if content.trimmingCharacters(in: .whitespacesAndNewlines) == "{}" { return true } - if let reasoning, !reasoning.isEmpty { - try await yieldChunked( - reasoning, - as: { .reasoning($0) }, - continuation: continuation, - delay: chunkDelay - ) - } - if !content.isEmpty { - try await yieldChunked( - content, - as: { .token($0) }, - continuation: continuation, - delay: chunkDelay - ) - } - return content.isEmpty && (reasoning?.isEmpty ?? true) - } - func emitUsage( _ usage: ChatCompletionResponse.Usage?, aggregate: TokenUsage, diff --git a/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Agent.swift b/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Agent.swift index ccefe62..c40e139 100644 --- a/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Agent.swift +++ b/openclient-llm/Shared/Features/Chat/ViewModels/ChatViewModel+Agent.swift @@ -129,7 +129,6 @@ extension ChatViewModel { private extension ChatViewModel { func handleAgentStreamFailure(_ error: Error, assistantMessageId: UUID, modelId: String) async { guard !Task.isCancelled, isActiveStream(assistantMessageId) else { return } - flushStreamingTextUpdates(for: assistantMessageId) guard case .loaded(var currentState) = state else { return } LogManager.error("performAgentStreaming error model=\(modelId): \(error)") if let index = currentState.messages.firstIndex(where: { $0.id == assistantMessageId }), @@ -159,25 +158,29 @@ private extension ChatViewModel { } switch event { case .token(let text): - let didPublish = enqueueStreamingTextUpdate(.token(text), assistantMessageId: assistantMessageId) - if didPublish { await Task.yield() } + guard !text.isEmpty else { return true } + return await publishAgentTextUpdate(.token(text), assistantMessageId: assistantMessageId) case .reasoning(let text): - let didPublish = enqueueStreamingTextUpdate(.reasoning(text), assistantMessageId: assistantMessageId) - if didPublish { await Task.yield() } + guard !text.isEmpty else { return true } + return await publishAgentTextUpdate(.reasoning(text), assistantMessageId: assistantMessageId) case .completed: break default: guard case .loaded(var currentState) = state else { return false } - if !streamingUpdateBuffer.updates.isEmpty { - let updates = takeStreamingTextUpdates(for: assistantMessageId) - applyStreamingTextUpdates(updates, to: ¤tState, assistantMessageId: assistantMessageId) - } applyAgentEvent(event, to: ¤tState, assistantMessageId: assistantMessageId) state = .loaded(currentState) } return true } + func publishAgentTextUpdate(_ update: StreamingTextUpdate, assistantMessageId: UUID) async -> Bool { + guard case .loaded(var currentState) = state else { return false } + applyStreamingTextUpdates([update], to: ¤tState, assistantMessageId: assistantMessageId) + state = .loaded(currentState) + await Task.yield() + return true + } + private func streamingBackgroundPhase(for event: AgentEvent) -> StreamingBackgroundPhase? { switch event { case .token: @@ -340,8 +343,6 @@ private extension ChatViewModel { reportedPromptTokens: Int? ) async { guard isActiveStream(assistantId), case .loaded(var finalState) = state else { return } - let updates = takeStreamingTextUpdates(for: assistantId) - applyStreamingTextUpdates(updates, to: &finalState, assistantMessageId: assistantId) finalState.isStreaming = false finalState.isSearchingWeb = false finalState.activeToolCallIds = [] diff --git a/specs/agent-tool-calling.instructions.md b/specs/agent-tool-calling.instructions.md index 0059928..a0e4aef 100644 --- a/specs/agent-tool-calling.instructions.md +++ b/specs/agent-tool-calling.instructions.md @@ -298,7 +298,7 @@ protocol AgentStreamUseCaseProtocol: Sendable { model: String, parameters: ModelParameters, contextWindowTokens: Int?, - toolRegistry: ToolRegistry + toolContext: AgentToolContext ) -> AsyncThrowingStream } @@ -309,15 +309,16 @@ enum AgentEvent: Sendable { case toolCallCompleted(toolCallId: String, result: String, searchResults: [LiteLLMSearchResult]?) case transcriptAppended([ChatMessage]) case usage(TokenUsage) - case promptUsage(Int) + case promptUsage(Int?) case image(Data) case completed } ``` The use case manages non-streaming completion requests for the full loop, then emits final content and reasoning in locally -paced, adaptively sized chunks for the existing streaming UI. Chunk count is bounded so presentation adds no more than one -second per reasoning or answer field. Failures terminate the `AsyncThrowingStream`; there is no `.error` event. Assistant +paced, adaptively sized chunks for the existing streaming UI. Chunk count bounds requested pacing sleeps to approximately six +seconds per reasoning or answer field, and the request timeout pauses once valid final content has arrived. Failures terminate +the `AsyncThrowingStream`; there is no `.error` event. Assistant tool-call messages and matching tool messages are emitted through `.transcriptAppended` and persisted before the next round. Agent transcript messages remain in `ChatViewModel` for context and persistence, but presentation snapshots must exclude diff --git a/specs/chat-visual-style.instructions.md b/specs/chat-visual-style.instructions.md index f14d5e5..a0e3127 100644 --- a/specs/chat-visual-style.instructions.md +++ b/specs/chat-visual-style.instructions.md @@ -136,8 +136,10 @@ Modern, clean conversational interface inspired by leading AI chat applications. ### Progressive Rendering - Tokens appear immediately as they arrive from the stream -- After the first fragment, coalesce routine text mutations on a 50 ms cadence; payload size must not force additional - same-frame publications, while lifecycle and non-text events may flush once to preserve content and event order +- After the first server-streamed fragment, coalesce routine text mutations on a 50 ms cadence; payload size must not force + additional same-frame publications, while lifecycle and non-text events may flush once to preserve content and event order +- Locally simulated agent final content already arrives in bounded, 20 ms paced chunks and must bypass the server-streaming + coalescer to avoid combining both presentation layers - Use `ScrollViewReader` with explicit top and bottom sentinels; never bind `ScrollPosition` to the chat scroll view - Keep message rows in an eager `VStack`. `LazyVStack` can enter a non-converging layout pass when upward user scrolling overlaps live message updates, freezing both iOS and macOS From 511982177af6e7db63cb46fcd4375557ca41f652 Mon Sep 17 00:00:00 2001 From: Arturo Carretero Calvo <10163049+ArtCC@users.noreply.github.com> Date: Fri, 28 Aug 2026 07:45:22 +0200 Subject: [PATCH 2/2] Bump changelog build version to 1.6.35-build-90 - Update the CHANGELOG entry from 1.6.35-build-89 to 1.6.35-build-90 - Keep the release date unchanged at 2026-08-28 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dd13e5..6e166a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Contributions are welcome โ€” see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. -## [1.6.35-build-89] - 2026-08-28 +## [1.6.35-build-90] - 2026-08-28 ### Added