From 7d69cb4979a21221d157f02764fdd6849593de58 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 6 May 2026 22:24:04 -0700 Subject: [PATCH] Trying to add spacer at the end of the transcript --- .../Sybil/SybilChatTranscriptView.swift | 41 ++++- .../Sources/Sybil/SybilWorkspaceView.swift | 143 +++++++++++++++++- .../Sybil/Tests/SybilTests/SybilTests.swift | 20 +++ 3 files changed, 202 insertions(+), 2 deletions(-) diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift index 57ac51e..7cefbf6 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift @@ -7,6 +7,9 @@ struct SybilChatTranscriptView: View { var isSending: Bool var topContentInset: CGFloat = 0 var bottomContentInset: CGFloat = 0 + var tailSpacerHeight: CGFloat = 0 + var onViewportHeightChange: ((CGFloat) -> Void)? = nil + var onPendingAssistantHeightChange: ((CGFloat) -> Void)? = nil private var hasPendingAssistant: Bool { messages.contains { message in @@ -20,6 +23,16 @@ struct SybilChatTranscriptView: View { ForEach(messages.reversed()) { message in MessageBubble(message: message, isSending: isSending) .frame(maxWidth: .infinity) + .background { + if isStreamingPendingAssistant(message) { + GeometryReader { proxy in + Color.clear.preference( + key: SybilPendingAssistantHeightPreferenceKey.self, + value: proxy.size.height + ) + } + } + } .scaleEffect(x: 1, y: -1) } @@ -33,13 +46,39 @@ struct SybilChatTranscriptView: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 14) - .padding(.top, 18 + bottomContentInset) + .padding(.top, 18 + bottomContentInset + tailSpacerHeight) .padding(.bottom, 18 + topContentInset) } .frame(maxWidth: .infinity, alignment: .leading) .scrollDismissesKeyboard(.interactively) + .background { + GeometryReader { proxy in + Color.clear + .onAppear { + onViewportHeightChange?(proxy.size.height) + } + .onChange(of: proxy.size.height) { _, height in + onViewportHeightChange?(height) + } + } + } + .onPreferenceChange(SybilPendingAssistantHeightPreferenceKey.self) { height in + onPendingAssistantHeightChange?(height) + } .scaleEffect(x: 1, y: -1) } + + private func isStreamingPendingAssistant(_ message: Message) -> Bool { + isSending && message.id.hasPrefix("temp-assistant-") + } +} + +private struct SybilPendingAssistantHeightPreferenceKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } } private struct MessageBubble: View { diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift index 0da6781..862cde9 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift @@ -26,6 +26,10 @@ struct SybilWorkspaceView: View { @State private var isShowingPhotoPicker = false @State private var photoPickerItems: [PhotosPickerItem] = [] @State private var isComposerDropTargeted = false + @State private var transcriptTailSpacerHeight = SybilTranscriptTailSpacer.minimumHeight + @State private var transcriptTailSpacerTargetHeight = SybilTranscriptTailSpacer.minimumHeight + @State private var transcriptViewportHeight: CGFloat = 0 + @State private var pendingAssistantBaselineHeight: CGFloat? @State private var newChatSwipeOffset: CGFloat = 0 @State private var newChatSwipeCompletionOffset: CGFloat = 0 @State private var newChatSwipeContainerWidth: CGFloat = NewChatSwipeMetrics.referenceWidth @@ -38,6 +42,10 @@ struct SybilWorkspaceView: View { private let customWorkspaceNavigationContentInset: CGFloat = 96 private let composerOverlayContentInset: CGFloat = 112 + private var visibleTranscriptTailSpacerHeight: CGFloat { + viewModel.showsComposer && !viewModel.isSearchMode ? transcriptTailSpacerHeight : 0 + } + private var isSettingsSelected: Bool { if case .settings = viewModel.selectedItem { return true @@ -145,6 +153,17 @@ struct SybilWorkspaceView: View { } resetNewChatSwipe(animated: false) } + .onChange(of: transcriptScrollContextID) { _, _ in + handleTranscriptContextChange() + } + .onChange(of: viewModel.isSendingVisibleChat) { wasSending, isSending in + handleVisibleChatSendingChange(wasSending: wasSending, isSending: isSending) + } + .onChange(of: viewModel.errorMessage) { _, message in + if message != nil && !viewModel.isSendingVisibleChat { + resetTranscriptTailSpacer(animated: true) + } + } .task(id: composerFocusPolicyID) { await applyComposerFocusPolicy() } @@ -194,7 +213,14 @@ struct SybilWorkspaceView: View { isLoading: viewModel.isLoadingSelection, isSending: viewModel.isSendingVisibleChat, topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0, - bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0 + bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0, + tailSpacerHeight: visibleTranscriptTailSpacerHeight, + onViewportHeightChange: { height in + handleTranscriptViewportHeightChange(height) + }, + onPendingAssistantHeightChange: { height in + handlePendingAssistantHeightChange(height) + } ) .id(transcriptScrollContextID) } @@ -285,6 +311,86 @@ struct SybilWorkspaceView: View { } } + private func handleTranscriptContextChange() { + resetTranscriptTailSpacer(animated: false) + } + + private func handleVisibleChatSendingChange(wasSending: Bool, isSending: Bool) { + guard !viewModel.isSearchMode else { + resetTranscriptTailSpacer(animated: true) + return + } + + if isSending { + prepareTranscriptTailSpacerForReply(animated: false) + return + } + + if wasSending { + if viewModel.errorMessage != nil { + resetTranscriptTailSpacer(animated: true) + } + pendingAssistantBaselineHeight = nil + } + } + + private func handleTranscriptViewportHeightChange(_ height: CGFloat) { + transcriptViewportHeight = height + + if viewModel.isSendingVisibleChat, + transcriptTailSpacerTargetHeight <= SybilTranscriptTailSpacer.minimumHeight { + prepareTranscriptTailSpacerForReply(animated: false) + } + } + + private func handlePendingAssistantHeightChange(_ height: CGFloat) { + guard viewModel.isSendingVisibleChat, !viewModel.isSearchMode, height > 0 else { + return + } + + if pendingAssistantBaselineHeight == nil { + pendingAssistantBaselineHeight = height + } + + let measuredHeight = SybilTranscriptTailSpacer.placeholderHeight( + targetHeight: transcriptTailSpacerTargetHeight, + baselineAssistantHeight: pendingAssistantBaselineHeight ?? height, + currentAssistantHeight: height + ) + let nextHeight = min(transcriptTailSpacerHeight, measuredHeight) + setTranscriptTailSpacer(nextHeight, animated: false) + } + + private func prepareTranscriptTailSpacerForReply(animated: Bool) { + let targetHeight = SybilTranscriptTailSpacer.replyBufferHeight(for: transcriptViewportHeight) + transcriptTailSpacerTargetHeight = targetHeight + pendingAssistantBaselineHeight = nil + setTranscriptTailSpacer(targetHeight, animated: animated) + } + + private func resetTranscriptTailSpacer(animated: Bool) { + transcriptTailSpacerTargetHeight = SybilTranscriptTailSpacer.minimumHeight + pendingAssistantBaselineHeight = nil + setTranscriptTailSpacer(SybilTranscriptTailSpacer.minimumHeight, animated: animated) + } + + private func setTranscriptTailSpacer(_ height: CGFloat, animated: Bool) { + let nextHeight = SybilTranscriptTailSpacer.clampedHeight(height) + guard abs(nextHeight - transcriptTailSpacerHeight) >= 0.5 else { + return + } + + let update = { + transcriptTailSpacerHeight = nextHeight + } + + if animated { + withAnimation(.easeOut(duration: 0.22), update) + } else { + update() + } + } + private func beginNewChatSwipe(containerWidth: CGFloat) { let update = { newChatSwipeContainerWidth = max(containerWidth, 1) @@ -702,6 +808,10 @@ struct SybilWorkspaceView: View { return } + if !viewModel.isSearchMode { + prepareTranscriptTailSpacerForReply(animated: false) + } + #if !targetEnvironment(macCatalyst) if !viewModel.isSearchMode { composerFocused = false @@ -771,6 +881,37 @@ struct SybilWorkspaceView: View { } } +enum SybilTranscriptTailSpacer { + static let minimumHeight: CGFloat = 20 + static let replyBufferMin: CGFloat = 288 + static let replyBufferMax: CGFloat = 576 + static let replyBufferViewportRatio: CGFloat = 0.52 + + static func replyBufferHeight(for viewportHeight: CGFloat) -> CGFloat { + guard viewportHeight > 0 else { + return replyBufferMin + } + + return min( + replyBufferMax, + max(replyBufferMin, (viewportHeight * replyBufferViewportRatio).rounded()) + ) + } + + static func clampedHeight(_ height: CGFloat) -> CGFloat { + max(minimumHeight, height.rounded(.up)) + } + + static func placeholderHeight( + targetHeight: CGFloat, + baselineAssistantHeight: CGFloat, + currentAssistantHeight: CGFloat + ) -> CGFloat { + let consumedHeight = max(currentAssistantHeight - baselineAssistantHeight, 0) + return clampedHeight(targetHeight - consumedHeight) + } +} + enum NewChatSwipeMetrics { static let referenceWidth: CGFloat = 390 static let horizontalActivationDistance: CGFloat = 18 diff --git a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift index 3835851..1aa9fc9 100644 --- a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift +++ b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift @@ -784,3 +784,23 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD #expect(BackSwipeMetrics.shouldComplete(offset: 24, velocityX: 800, width: width, isLatched: false)) #expect(!BackSwipeMetrics.shouldComplete(offset: latchDistance + 1, velocityX: -800, width: width, isLatched: true)) } + +@Test func transcriptTailSpacerContractsAsContentGrows() async throws { + let targetHeight: CGFloat = 320 + let baselineAssistantHeight: CGFloat = 28 + + #expect( + SybilTranscriptTailSpacer.placeholderHeight( + targetHeight: targetHeight, + baselineAssistantHeight: baselineAssistantHeight, + currentAssistantHeight: baselineAssistantHeight + 120 + ) == 200 + ) + #expect( + SybilTranscriptTailSpacer.placeholderHeight( + targetHeight: targetHeight, + baselineAssistantHeight: baselineAssistantHeight, + currentAssistantHeight: baselineAssistantHeight + 500 + ) == SybilTranscriptTailSpacer.minimumHeight + ) +}