From 22aa6522571435b82ca8a7a77b478b1b7fef8e28 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 7 Jun 2026 19:58:04 -0700 Subject: [PATCH] Fix iOS chat scroll pinning --- .../Sybil/SybilChatTranscriptView.swift | 71 ++++++++++++------- .../Sybil/Sources/Sybil/SybilViewModel.swift | 8 +++ .../Sources/Sybil/SybilWorkspaceView.swift | 3 +- .../Sybil/Tests/SybilTests/SybilTests.swift | 32 +++++++++ 4 files changed, 86 insertions(+), 28 deletions(-) diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift index 7b91d5d..2e80187 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift @@ -7,38 +7,55 @@ struct SybilChatTranscriptView: View { var isSending: Bool var topContentInset: CGFloat = 0 var bottomContentInset: CGFloat = 0 + var bottomPinRequestID: Int = 0 - private var hasPendingAssistant: Bool { - messages.contains { message in - message.id.hasPrefix("temp-assistant-") && message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + private let bottomAnchorID = "sybil-chat-transcript-bottom-anchor" + + var body: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 26) { + if isLoading && messages.isEmpty { + Text("Loading messages…") + .font(.sybil(.footnote)) + .foregroundStyle(SybilTheme.textMuted) + .padding(.top, 24) + } + + ForEach(messages) { message in + MessageBubble(message: message, isSending: isSending) + .frame(maxWidth: .infinity) + } + + Color.clear + .frame(height: 18 + bottomContentInset) + .id(bottomAnchorID) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.top, 18 + topContentInset) + } + .frame(maxWidth: .infinity, alignment: .leading) + .scrollDismissesKeyboard(.interactively) + .onAppear { + scrollToBottom(with: proxy, animated: false) + } + .onChange(of: bottomPinRequestID) { _, _ in + scrollToBottom(with: proxy, animated: true) + } } } - var body: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 26) { - ForEach(messages.reversed()) { message in - MessageBubble(message: message, isSending: isSending) - .frame(maxWidth: .infinity) - .scaleEffect(x: 1, y: -1) - } - - if isLoading && messages.isEmpty { - Text("Loading messages…") - .font(.sybil(.footnote)) - .foregroundStyle(SybilTheme.textMuted) - .padding(.top, 24) - .scaleEffect(x: 1, y: -1) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 14) - .padding(.top, 18 + bottomContentInset) - .padding(.bottom, 18 + topContentInset) + private func scrollToBottom(with proxy: ScrollViewProxy, animated: Bool) { + let action = { + proxy.scrollTo(bottomAnchorID, anchor: .bottom) + } + + if animated { + withAnimation(.easeOut(duration: 0.18), action) + } else { + action() } - .frame(maxWidth: .infinity, alignment: .leading) - .scrollDismissesKeyboard(.interactively) - .scaleEffect(x: 1, y: -1) } } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift index fba584b..873db2f 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift @@ -107,6 +107,7 @@ final class SybilViewModel { var isLoadingCollections = false var isLoadingSelection = false var isCreatingSearchChat = false + var chatBottomPinRequestID = 0 var errorMessage: String? var composer = "" @@ -1699,6 +1700,10 @@ final class SybilViewModel { isLoadingSelection = false } + private func requestChatBottomPin() { + chatBottomPinRequestID += 1 + } + private func startSelectionRefreshTask() -> Task { isLoadingSelection = true let task = Task { [weak self] in @@ -1752,6 +1757,7 @@ final class SybilViewModel { } selectedChat = chat selectedSearch = nil + requestChatBottomPin() if let provider = chat.lastUsedProvider, let model = chat.lastUsedModel, @@ -1824,6 +1830,7 @@ final class SybilViewModel { } else { pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages) } + requestChatBottomPin() if chatID == nil { let created = try await client.createChat(title: nil) @@ -1871,6 +1878,7 @@ final class SybilViewModel { if let draftPending = pendingDraftChatState { pendingDraftChatState = nil pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: draftPending.messages) + requestChatBottomPin() } else if pendingChatStates[chatID] == nil { pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: optimisticMessages) } else { diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift index 7ff45ec..69843d7 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift @@ -194,7 +194,8 @@ struct SybilWorkspaceView: View { isLoading: viewModel.isLoadingSelection, isSending: viewModel.isSendingVisibleChat, topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0, - bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0 + bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0, + bottomPinRequestID: viewModel.chatBottomPinRequestID ) .id(transcriptScrollContextID) } diff --git a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift index 2c938ce..38ac0b9 100644 --- a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift +++ b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift @@ -495,6 +495,7 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD #expect(snapshot.listSearches == 0) #expect(snapshot.getChat == 1) #expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript") + #expect(viewModel.chatBottomPinRequestID == 1) } @MainActor @@ -682,6 +683,37 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD await sendTask.value } +@MainActor +@Test func chatBottomPinRequestDoesNotFollowAssistantStreaming() async throws { + let date = Date(timeIntervalSince1970: 1_700_000_245) + let chat = makeChatSummary(id: "chat-pin", date: date) + let detail = makeChatDetail(id: "chat-pin", date: date, body: "existing transcript") + let client = MockSybilClient( + chatsResponse: [chat], + chatDetails: ["chat-pin": detail] + ) + await client.setCompletionStreamEvents([ + .delta(CompletionStreamDelta(text: "partial ")), + .delta(CompletionStreamDelta(text: "response")), + .done(CompletionStreamDone(text: "partial response")) + ]) + let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } + viewModel.isAuthenticated = true + viewModel.isCheckingSession = false + viewModel.chats = [chat] + viewModel.workspaceItems = [WorkspaceItem(chat: chat)] + viewModel.selectedItem = .chat("chat-pin") + viewModel.selectedChat = detail + viewModel.composer = "continue" + + let initialPinRequestID = viewModel.chatBottomPinRequestID + await viewModel.sendComposer() + + let snapshot = await client.currentSnapshot() + #expect(snapshot.runCompletionStream == 1) + #expect(viewModel.chatBottomPinRequestID == initialPinRequestID + 1) +} + @MainActor @Test func quickQuestionRunsNonPersistentCompletionStream() async throws { let client = MockSybilClient()