Compare commits
4 Commits
wip/spacer
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 29c6dce0e5 | |||
| 5855b7edb8 | |||
| ac6d55f617 | |||
| 1e045db7f4 |
@@ -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.7
|
MARKETING_VERSION: 1.8
|
||||||
CURRENT_PROJECT_VERSION: 8
|
CURRENT_PROJECT_VERSION: 9
|
||||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ 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
|
||||||
@@ -23,16 +20,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,39 +33,13 @@ struct SybilChatTranscriptView: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.top, 18 + bottomContentInset + tailSpacerHeight)
|
.padding(.top, 18 + bottomContentInset)
|
||||||
.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 {
|
||||||
|
|||||||
@@ -219,6 +219,11 @@ struct SybilQuickQuestionView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func submitQuestion() {
|
private func submitQuestion() {
|
||||||
|
guard viewModel.canSendQuickQuestion else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
promptFocused = false
|
||||||
_ = viewModel.sendQuickQuestion()
|
_ = viewModel.sendQuickQuestion()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,10 +159,7 @@ struct SybilSidebarItemList: View {
|
|||||||
.padding(10)
|
.padding(10)
|
||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await viewModel.refreshVisibleContent(
|
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
|
||||||
refreshCollections: true,
|
|
||||||
refreshSelection: false
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
func sendComposer() async {
|
||||||
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let attachments = composerAttachments
|
let attachments = composerAttachments
|
||||||
@@ -1276,7 +1293,9 @@ final class SybilViewModel {
|
|||||||
attachToVisibleActiveRunIfNeeded()
|
attachToVisibleActiveRunIfNeeded()
|
||||||
}
|
}
|
||||||
} catch {
|
} 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")
|
SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive")
|
||||||
} else {
|
} else {
|
||||||
errorMessage = normalizeAPIError(error)
|
errorMessage = normalizeAPIError(error)
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ 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
|
||||||
@@ -42,10 +38,6 @@ 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
|
||||||
@@ -153,17 +145,6 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -213,14 +194,7 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -258,13 +232,7 @@ struct SybilWorkspaceView: View {
|
|||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
workspaceNavigationLeadingControl
|
workspaceNavigationLeadingControl
|
||||||
|
|
||||||
Text(viewModel.selectedTitle)
|
customWorkspaceNavigationTitle
|
||||||
.font(.sybil(size: 16, weight: .semibold))
|
|
||||||
.foregroundStyle(SybilTheme.text)
|
|
||||||
.lineLimit(1)
|
|
||||||
.minimumScaleFactor(0.78)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
|
|
||||||
workspaceNavigationTrailingControl
|
workspaceNavigationTrailingControl
|
||||||
}
|
}
|
||||||
@@ -277,6 +245,32 @@ 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 {
|
||||||
@@ -311,86 +305,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) {
|
private func beginNewChatSwipe(containerWidth: CGFloat) {
|
||||||
let update = {
|
let update = {
|
||||||
newChatSwipeContainerWidth = max(containerWidth, 1)
|
newChatSwipeContainerWidth = max(containerWidth, 1)
|
||||||
@@ -808,14 +722,8 @@ struct SybilWorkspaceView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !viewModel.isSearchMode {
|
|
||||||
prepareTranscriptTailSpacerForReply(animated: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !targetEnvironment(macCatalyst)
|
#if !targetEnvironment(macCatalyst)
|
||||||
if !viewModel.isSearchMode {
|
composerFocused = false
|
||||||
composerFocused = false
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
@@ -881,37 +789,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 {
|
enum NewChatSwipeMetrics {
|
||||||
static let referenceWidth: CGFloat = 390
|
static let referenceWidth: CGFloat = 390
|
||||||
static let horizontalActivationDistance: CGFloat = 18
|
static let horizontalActivationDistance: CGFloat = 18
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ 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?
|
||||||
@@ -85,6 +87,11 @@ 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
|
||||||
}
|
}
|
||||||
@@ -122,6 +129,9 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +175,9 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,6 +396,33 @@ 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)
|
||||||
@@ -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: 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