1 Commits

Author SHA1 Message Date
7d69cb4979 Trying to add spacer at the end of the transcript 2026-05-06 22:24:04 -07:00
7 changed files with 219 additions and 98 deletions

View File

@@ -24,8 +24,8 @@ targets:
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
INFOPLIST_FILE: Apps/Sybil/Info.plist INFOPLIST_FILE: Apps/Sybil/Info.plist
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
MARKETING_VERSION: 1.8 MARKETING_VERSION: 1.7
CURRENT_PROJECT_VERSION: 9 CURRENT_PROJECT_VERSION: 8
INFOPLIST_KEY_CFBundleDisplayName: Sybil INFOPLIST_KEY_CFBundleDisplayName: Sybil
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES

View File

@@ -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 {

View File

@@ -219,11 +219,6 @@ struct SybilQuickQuestionView: View {
} }
private func submitQuestion() { private func submitQuestion() {
guard viewModel.canSendQuickQuestion else {
return
}
promptFocused = false
_ = viewModel.sendQuickQuestion() _ = viewModel.sendQuickQuestion()
} }
} }

View File

@@ -159,7 +159,10 @@ struct SybilSidebarItemList: View {
.padding(10) .padding(10)
} }
.refreshable { .refreshable {
await viewModel.refreshSidebarCollectionsFromPullToRefresh() await viewModel.refreshVisibleContent(
refreshCollections: true,
refreshSelection: false
)
} }
} }
} }

View File

@@ -911,23 +911,6 @@ final class SybilViewModel {
} }
} }
func refreshSidebarCollectionsFromPullToRefresh() async {
guard isAuthenticated, !isCheckingSession else {
return
}
SybilLog.info(
SybilLog.ui,
"Sidebar pull-to-refresh requested"
)
let preferredSelection = selectedItem
let refreshTask = Task { @MainActor in
await refreshCollections(preferredSelection: preferredSelection, refreshSelection: false)
}
await refreshTask.value
}
func sendComposer() async { func sendComposer() async {
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines) let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
let attachments = composerAttachments let attachments = composerAttachments
@@ -1293,9 +1276,7 @@ final class SybilViewModel {
attachToVisibleActiveRunIfNeeded() attachToVisibleActiveRunIfNeeded()
} }
} catch { } catch {
if isCancellation(error) { if shouldSuppressInactiveTransportError(error) {
SybilLog.debug(SybilLog.app, "Collection refresh cancelled")
} else if shouldSuppressInactiveTransportError(error) {
SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive") SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive")
} else { } else {
errorMessage = normalizeAPIError(error) errorMessage = normalizeAPIError(error)

View File

@@ -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)
} }
@@ -232,7 +258,13 @@ struct SybilWorkspaceView: View {
HStack(spacing: 14) { HStack(spacing: 14) {
workspaceNavigationLeadingControl workspaceNavigationLeadingControl
customWorkspaceNavigationTitle Text(viewModel.selectedTitle)
.font(.sybil(size: 16, weight: .semibold))
.foregroundStyle(SybilTheme.text)
.lineLimit(1)
.minimumScaleFactor(0.78)
.frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(.leading)
workspaceNavigationTrailingControl workspaceNavigationTrailingControl
} }
@@ -245,32 +277,6 @@ struct SybilWorkspaceView: View {
} }
} }
private var selectedProviderModelSubtitle: String {
let selectedModel = viewModel.model.trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedModel.isEmpty else {
return viewModel.provider.displayName
}
return "\(viewModel.provider.displayName)\(selectedModel)"
}
private var customWorkspaceNavigationTitle: some View {
VStack(alignment: .leading, spacing: 2) {
Text(viewModel.selectedTitle)
.font(.sybil(size: 16, weight: .semibold))
.foregroundStyle(SybilTheme.text)
.lineLimit(1)
.minimumScaleFactor(0.78)
Text(selectedProviderModelSubtitle)
.font(.sybil(size: 10, weight: .medium))
.foregroundStyle(SybilTheme.textMuted)
.lineLimit(1)
.minimumScaleFactor(0.82)
}
.frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(.leading)
}
@ViewBuilder @ViewBuilder
private var workspaceNavigationLeadingControl: some View { private var workspaceNavigationLeadingControl: some View {
switch navigationLeadingControl { switch navigationLeadingControl {
@@ -305,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)
@@ -722,8 +808,14 @@ struct SybilWorkspaceView: View {
return return
} }
if !viewModel.isSearchMode {
prepareTranscriptTailSpacerForReply(animated: false)
}
#if !targetEnvironment(macCatalyst) #if !targetEnvironment(macCatalyst)
composerFocused = false if !viewModel.isSearchMode {
composerFocused = false
}
#endif #endif
Task { Task {
@@ -789,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

View File

@@ -36,8 +36,6 @@ private actor MockSybilClient: SybilAPIClienting {
private var lastCreateChatCall: ChatCreateCallSnapshot? private var lastCreateChatCall: ChatCreateCallSnapshot?
private var lastCompletionStreamBody: CompletionStreamRequest? private var lastCompletionStreamBody: CompletionStreamRequest?
private var completionStreamEvents: [CompletionStreamEvent]? private var completionStreamEvents: [CompletionStreamEvent]?
private var listChatsDelayNanoseconds: UInt64 = 0
private var listSearchesDelayNanoseconds: UInt64 = 0
private var getChatDelayNanoseconds: UInt64 = 0 private var getChatDelayNanoseconds: UInt64 = 0
private var getSearchDelayNanoseconds: UInt64 = 0 private var getSearchDelayNanoseconds: UInt64 = 0
private var completionStreamNetworkErrorMessage: String? private var completionStreamNetworkErrorMessage: String?
@@ -87,11 +85,6 @@ private actor MockSybilClient: SybilAPIClienting {
completionStreamDelayNanoseconds = delayNanoseconds completionStreamDelayNanoseconds = delayNanoseconds
} }
func setListDelays(chats: UInt64 = 0, searches: UInt64 = 0) {
listChatsDelayNanoseconds = chats
listSearchesDelayNanoseconds = searches
}
func setGetChatDelay(_ delayNanoseconds: UInt64) { func setGetChatDelay(_ delayNanoseconds: UInt64) {
getChatDelayNanoseconds = delayNanoseconds getChatDelayNanoseconds = delayNanoseconds
} }
@@ -129,9 +122,6 @@ private actor MockSybilClient: SybilAPIClienting {
func listChats() async throws -> [ChatSummary] { func listChats() async throws -> [ChatSummary] {
snapshot.listChats += 1 snapshot.listChats += 1
if listChatsDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: listChatsDelayNanoseconds)
}
return chatsResponse return chatsResponse
} }
@@ -175,9 +165,6 @@ private actor MockSybilClient: SybilAPIClienting {
func listSearches() async throws -> [SearchSummary] { func listSearches() async throws -> [SearchSummary] {
snapshot.listSearches += 1 snapshot.listSearches += 1
if listSearchesDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: listSearchesDelayNanoseconds)
}
return searchesResponse return searchesResponse
} }
@@ -396,33 +383,6 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
#expect(viewModel.selectedItem == .chat("chat-1")) #expect(viewModel.selectedItem == .chat("chat-1"))
} }
@MainActor
@Test func pullToRefreshCompletesWhenRefreshableTaskIsCancelled() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_050)
let chat = makeChatSummary(id: "chat-cancelled", date: date)
let search = makeSearchSummary(id: "search-cancelled", date: date)
let client = MockSybilClient(
chatsResponse: [chat],
searchesResponse: [search]
)
await client.setListDelays(chats: 50_000_000, searches: 50_000_000)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
let refreshTask = Task {
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
}
try await Task.sleep(nanoseconds: 10_000_000)
refreshTask.cancel()
await refreshTask.value
#expect(viewModel.errorMessage == nil)
#expect(!viewModel.isLoadingCollections)
#expect(viewModel.chats.map(\.id) == ["chat-cancelled"])
#expect(viewModel.searches.map(\.id) == ["search-cancelled"])
}
@MainActor @MainActor
@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws { @Test func foregroundChatRefreshReloadsSelectedTranscript() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_100) let date = Date(timeIntervalSince1970: 1_700_000_100)
@@ -824,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
)
}