Fix iOS chat scroll pinning
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user