Fix iOS chat scroll pinning

This commit is contained in:
2026-06-07 19:58:04 -07:00
parent 8f6e8c17a5
commit 22aa652257
4 changed files with 86 additions and 28 deletions

View File

@@ -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) {
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)
}
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 + bottomContentInset)
.padding(.bottom, 18 + topContentInset)
.padding(.top, 18 + topContentInset)
}
.frame(maxWidth: .infinity, alignment: .leading)
.scrollDismissesKeyboard(.interactively)
.scaleEffect(x: 1, y: -1)
.onAppear {
scrollToBottom(with: proxy, animated: false)
}
.onChange(of: bottomPinRequestID) { _, _ in
scrollToBottom(with: proxy, animated: true)
}
}
}
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()
}
}
}

View File

@@ -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<Void, Never> {
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 {

View File

@@ -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)
}

View File

@@ -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()