Compare commits
2 Commits
wip/spacer
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ac6d55f617 | |||
| 1e045db7f4 |
@@ -24,8 +24,8 @@ targets:
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
INFOPLIST_FILE: Apps/Sybil/Info.plist
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
MARKETING_VERSION: 1.7
|
||||
CURRENT_PROJECT_VERSION: 8
|
||||
MARKETING_VERSION: 1.8
|
||||
CURRENT_PROJECT_VERSION: 9
|
||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||
|
||||
@@ -7,9 +7,6 @@ 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
|
||||
@@ -23,16 +20,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -46,39 +33,13 @@ struct SybilChatTranscriptView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 18 + bottomContentInset + tailSpacerHeight)
|
||||
.padding(.top, 18 + bottomContentInset)
|
||||
.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 {
|
||||
|
||||
@@ -159,10 +159,7 @@ struct SybilSidebarItemList: View {
|
||||
.padding(10)
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.refreshVisibleContent(
|
||||
refreshCollections: true,
|
||||
refreshSelection: false
|
||||
)
|
||||
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -911,6 +911,23 @@ 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 {
|
||||
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let attachments = composerAttachments
|
||||
@@ -1276,7 +1293,9 @@ final class SybilViewModel {
|
||||
attachToVisibleActiveRunIfNeeded()
|
||||
}
|
||||
} catch {
|
||||
if shouldSuppressInactiveTransportError(error) {
|
||||
if isCancellation(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")
|
||||
} else {
|
||||
errorMessage = normalizeAPIError(error)
|
||||
|
||||
@@ -26,10 +26,6 @@ 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
|
||||
@@ -42,10 +38,6 @@ 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
|
||||
@@ -153,17 +145,6 @@ 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()
|
||||
}
|
||||
@@ -213,14 +194,7 @@ struct SybilWorkspaceView: View {
|
||||
isLoading: viewModel.isLoadingSelection,
|
||||
isSending: viewModel.isSendingVisibleChat,
|
||||
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
||||
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
|
||||
tailSpacerHeight: visibleTranscriptTailSpacerHeight,
|
||||
onViewportHeightChange: { height in
|
||||
handleTranscriptViewportHeightChange(height)
|
||||
},
|
||||
onPendingAssistantHeightChange: { height in
|
||||
handlePendingAssistantHeightChange(height)
|
||||
}
|
||||
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0
|
||||
)
|
||||
.id(transcriptScrollContextID)
|
||||
}
|
||||
@@ -311,86 +285,6 @@ 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)
|
||||
@@ -808,10 +702,6 @@ struct SybilWorkspaceView: View {
|
||||
return
|
||||
}
|
||||
|
||||
if !viewModel.isSearchMode {
|
||||
prepareTranscriptTailSpacerForReply(animated: false)
|
||||
}
|
||||
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
if !viewModel.isSearchMode {
|
||||
composerFocused = false
|
||||
@@ -881,37 +771,6 @@ 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
|
||||
|
||||
@@ -36,6 +36,8 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
private var lastCreateChatCall: ChatCreateCallSnapshot?
|
||||
private var lastCompletionStreamBody: CompletionStreamRequest?
|
||||
private var completionStreamEvents: [CompletionStreamEvent]?
|
||||
private var listChatsDelayNanoseconds: UInt64 = 0
|
||||
private var listSearchesDelayNanoseconds: UInt64 = 0
|
||||
private var getChatDelayNanoseconds: UInt64 = 0
|
||||
private var getSearchDelayNanoseconds: UInt64 = 0
|
||||
private var completionStreamNetworkErrorMessage: String?
|
||||
@@ -85,6 +87,11 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
completionStreamDelayNanoseconds = delayNanoseconds
|
||||
}
|
||||
|
||||
func setListDelays(chats: UInt64 = 0, searches: UInt64 = 0) {
|
||||
listChatsDelayNanoseconds = chats
|
||||
listSearchesDelayNanoseconds = searches
|
||||
}
|
||||
|
||||
func setGetChatDelay(_ delayNanoseconds: UInt64) {
|
||||
getChatDelayNanoseconds = delayNanoseconds
|
||||
}
|
||||
@@ -122,6 +129,9 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
|
||||
func listChats() async throws -> [ChatSummary] {
|
||||
snapshot.listChats += 1
|
||||
if listChatsDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: listChatsDelayNanoseconds)
|
||||
}
|
||||
return chatsResponse
|
||||
}
|
||||
|
||||
@@ -165,6 +175,9 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
|
||||
func listSearches() async throws -> [SearchSummary] {
|
||||
snapshot.listSearches += 1
|
||||
if listSearchesDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: listSearchesDelayNanoseconds)
|
||||
}
|
||||
return searchesResponse
|
||||
}
|
||||
|
||||
@@ -383,6 +396,33 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
||||
#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
|
||||
@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_100)
|
||||
@@ -784,23 +824,3 @@ 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
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user