Fix iOS chat scroll pinning
This commit is contained in:
@@ -7,38 +7,55 @@ struct SybilChatTranscriptView: View {
|
|||||||
var isSending: Bool
|
var isSending: Bool
|
||||||
var topContentInset: CGFloat = 0
|
var topContentInset: CGFloat = 0
|
||||||
var bottomContentInset: CGFloat = 0
|
var bottomContentInset: CGFloat = 0
|
||||||
|
var bottomPinRequestID: Int = 0
|
||||||
|
|
||||||
private var hasPendingAssistant: Bool {
|
private let bottomAnchorID = "sybil-chat-transcript-bottom-anchor"
|
||||||
messages.contains { message in
|
|
||||||
message.id.hasPrefix("temp-assistant-") && message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
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 {
|
private func scrollToBottom(with proxy: ScrollViewProxy, animated: Bool) {
|
||||||
ScrollView {
|
let action = {
|
||||||
LazyVStack(alignment: .leading, spacing: 26) {
|
proxy.scrollTo(bottomAnchorID, anchor: .bottom)
|
||||||
ForEach(messages.reversed()) { message in
|
}
|
||||||
MessageBubble(message: message, isSending: isSending)
|
|
||||||
.frame(maxWidth: .infinity)
|
if animated {
|
||||||
.scaleEffect(x: 1, y: -1)
|
withAnimation(.easeOut(duration: 0.18), action)
|
||||||
}
|
} else {
|
||||||
|
action()
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.scrollDismissesKeyboard(.interactively)
|
|
||||||
.scaleEffect(x: 1, y: -1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ final class SybilViewModel {
|
|||||||
var isLoadingCollections = false
|
var isLoadingCollections = false
|
||||||
var isLoadingSelection = false
|
var isLoadingSelection = false
|
||||||
var isCreatingSearchChat = false
|
var isCreatingSearchChat = false
|
||||||
|
var chatBottomPinRequestID = 0
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
|
|
||||||
var composer = ""
|
var composer = ""
|
||||||
@@ -1699,6 +1700,10 @@ final class SybilViewModel {
|
|||||||
isLoadingSelection = false
|
isLoadingSelection = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func requestChatBottomPin() {
|
||||||
|
chatBottomPinRequestID += 1
|
||||||
|
}
|
||||||
|
|
||||||
private func startSelectionRefreshTask() -> Task<Void, Never> {
|
private func startSelectionRefreshTask() -> Task<Void, Never> {
|
||||||
isLoadingSelection = true
|
isLoadingSelection = true
|
||||||
let task = Task { [weak self] in
|
let task = Task { [weak self] in
|
||||||
@@ -1752,6 +1757,7 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
selectedChat = chat
|
selectedChat = chat
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
|
requestChatBottomPin()
|
||||||
|
|
||||||
if let provider = chat.lastUsedProvider,
|
if let provider = chat.lastUsedProvider,
|
||||||
let model = chat.lastUsedModel,
|
let model = chat.lastUsedModel,
|
||||||
@@ -1824,6 +1830,7 @@ final class SybilViewModel {
|
|||||||
} else {
|
} else {
|
||||||
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
|
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
|
||||||
}
|
}
|
||||||
|
requestChatBottomPin()
|
||||||
|
|
||||||
if chatID == nil {
|
if chatID == nil {
|
||||||
let created = try await client.createChat(title: nil)
|
let created = try await client.createChat(title: nil)
|
||||||
@@ -1871,6 +1878,7 @@ final class SybilViewModel {
|
|||||||
if let draftPending = pendingDraftChatState {
|
if let draftPending = pendingDraftChatState {
|
||||||
pendingDraftChatState = nil
|
pendingDraftChatState = nil
|
||||||
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: draftPending.messages)
|
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: draftPending.messages)
|
||||||
|
requestChatBottomPin()
|
||||||
} else if pendingChatStates[chatID] == nil {
|
} else if pendingChatStates[chatID] == nil {
|
||||||
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: optimisticMessages)
|
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: optimisticMessages)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -194,7 +194,8 @@ struct SybilWorkspaceView: View {
|
|||||||
isLoading: viewModel.isLoadingSelection,
|
isLoading: viewModel.isLoadingSelection,
|
||||||
isSending: viewModel.isSendingVisibleChat,
|
isSending: viewModel.isSendingVisibleChat,
|
||||||
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
||||||
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0
|
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
|
||||||
|
bottomPinRequestID: viewModel.chatBottomPinRequestID
|
||||||
)
|
)
|
||||||
.id(transcriptScrollContextID)
|
.id(transcriptScrollContextID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -495,6 +495,7 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
#expect(snapshot.listSearches == 0)
|
#expect(snapshot.listSearches == 0)
|
||||||
#expect(snapshot.getChat == 1)
|
#expect(snapshot.getChat == 1)
|
||||||
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
|
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
|
||||||
|
#expect(viewModel.chatBottomPinRequestID == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -682,6 +683,37 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
await sendTask.value
|
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
|
@MainActor
|
||||||
@Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
|
@Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
|
||||||
let client = MockSybilClient()
|
let client = MockSybilClient()
|
||||||
|
|||||||
Reference in New Issue
Block a user