Trying to add spacer at the end of the transcript
This commit is contained in:
@@ -7,6 +7,9 @@ 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 tailSpacerHeight: CGFloat = 0
|
||||||
|
var onViewportHeightChange: ((CGFloat) -> Void)? = nil
|
||||||
|
var onPendingAssistantHeightChange: ((CGFloat) -> Void)? = nil
|
||||||
|
|
||||||
private var hasPendingAssistant: Bool {
|
private var hasPendingAssistant: Bool {
|
||||||
messages.contains { message in
|
messages.contains { message in
|
||||||
@@ -20,6 +23,16 @@ struct SybilChatTranscriptView: View {
|
|||||||
ForEach(messages.reversed()) { message in
|
ForEach(messages.reversed()) { message in
|
||||||
MessageBubble(message: message, isSending: isSending)
|
MessageBubble(message: message, isSending: isSending)
|
||||||
.frame(maxWidth: .infinity)
|
.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)
|
.scaleEffect(x: 1, y: -1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,13 +46,39 @@ struct SybilChatTranscriptView: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.top, 18 + bottomContentInset)
|
.padding(.top, 18 + bottomContentInset + tailSpacerHeight)
|
||||||
.padding(.bottom, 18 + topContentInset)
|
.padding(.bottom, 18 + topContentInset)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.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)
|
.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 {
|
private struct MessageBubble: View {
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ struct SybilWorkspaceView: View {
|
|||||||
@State private var isShowingPhotoPicker = false
|
@State private var isShowingPhotoPicker = false
|
||||||
@State private var photoPickerItems: [PhotosPickerItem] = []
|
@State private var photoPickerItems: [PhotosPickerItem] = []
|
||||||
@State private var isComposerDropTargeted = false
|
@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 newChatSwipeOffset: CGFloat = 0
|
||||||
@State private var newChatSwipeCompletionOffset: CGFloat = 0
|
@State private var newChatSwipeCompletionOffset: CGFloat = 0
|
||||||
@State private var newChatSwipeContainerWidth: CGFloat = NewChatSwipeMetrics.referenceWidth
|
@State private var newChatSwipeContainerWidth: CGFloat = NewChatSwipeMetrics.referenceWidth
|
||||||
@@ -38,6 +42,10 @@ struct SybilWorkspaceView: View {
|
|||||||
private let customWorkspaceNavigationContentInset: CGFloat = 96
|
private let customWorkspaceNavigationContentInset: CGFloat = 96
|
||||||
private let composerOverlayContentInset: CGFloat = 112
|
private let composerOverlayContentInset: CGFloat = 112
|
||||||
|
|
||||||
|
private var visibleTranscriptTailSpacerHeight: CGFloat {
|
||||||
|
viewModel.showsComposer && !viewModel.isSearchMode ? transcriptTailSpacerHeight : 0
|
||||||
|
}
|
||||||
|
|
||||||
private var isSettingsSelected: Bool {
|
private var isSettingsSelected: Bool {
|
||||||
if case .settings = viewModel.selectedItem {
|
if case .settings = viewModel.selectedItem {
|
||||||
return true
|
return true
|
||||||
@@ -145,6 +153,17 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
resetNewChatSwipe(animated: false)
|
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) {
|
.task(id: composerFocusPolicyID) {
|
||||||
await applyComposerFocusPolicy()
|
await applyComposerFocusPolicy()
|
||||||
}
|
}
|
||||||
@@ -194,7 +213,14 @@ 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,
|
||||||
|
tailSpacerHeight: visibleTranscriptTailSpacerHeight,
|
||||||
|
onViewportHeightChange: { height in
|
||||||
|
handleTranscriptViewportHeightChange(height)
|
||||||
|
},
|
||||||
|
onPendingAssistantHeightChange: { height in
|
||||||
|
handlePendingAssistantHeightChange(height)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.id(transcriptScrollContextID)
|
.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) {
|
private func beginNewChatSwipe(containerWidth: CGFloat) {
|
||||||
let update = {
|
let update = {
|
||||||
newChatSwipeContainerWidth = max(containerWidth, 1)
|
newChatSwipeContainerWidth = max(containerWidth, 1)
|
||||||
@@ -702,6 +808,10 @@ struct SybilWorkspaceView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !viewModel.isSearchMode {
|
||||||
|
prepareTranscriptTailSpacerForReply(animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
#if !targetEnvironment(macCatalyst)
|
#if !targetEnvironment(macCatalyst)
|
||||||
if !viewModel.isSearchMode {
|
if !viewModel.isSearchMode {
|
||||||
composerFocused = false
|
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 {
|
enum NewChatSwipeMetrics {
|
||||||
static let referenceWidth: CGFloat = 390
|
static let referenceWidth: CGFloat = 390
|
||||||
static let horizontalActivationDistance: CGFloat = 18
|
static let horizontalActivationDistance: CGFloat = 18
|
||||||
|
|||||||
@@ -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: 24, velocityX: 800, width: width, isLatched: false))
|
||||||
#expect(!BackSwipeMetrics.shouldComplete(offset: latchDistance + 1, velocityX: -800, width: width, isLatched: true))
|
#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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user