diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift index ab92e13..1b48086 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift @@ -77,6 +77,27 @@ struct SybilPhoneShellView: View { private struct SybilPhoneSidebarRoot: View { @Bindable var viewModel: SybilViewModel @Binding var path: [PhoneRoute] + @State private var openingSelection: SidebarSelection? + @State private var openingRequestID: UUID? + + private var highlightedSelection: SidebarSelection? { + if let openingSelection { + return openingSelection + } + + guard let route = path.last else { + return nil + } + + switch route { + case let .chat(chatID): + return .chat(chatID) + case let .search(searchID): + return .search(searchID) + case .draftChat, .draftSearch, .settings: + return nil + } + } var body: some View { VStack(spacing: 0) { @@ -118,10 +139,16 @@ private struct SybilPhoneSidebarRoot: View { ScrollView { LazyVStack(alignment: .leading, spacing: 8) { ForEach(viewModel.sidebarItems) { item in - NavigationLink(value: PhoneRoute.from(selection: item.selection)) { + Button { + open(item.selection) + } label: { SybilPhoneSidebarRow(item: item) } - .buttonStyle(.plain) + .buttonStyle( + SybilPhoneSidebarRowButtonStyle( + isHighlighted: highlightedSelection == item.selection + ) + ) .contextMenu { Button(role: .destructive) { Task { @@ -150,17 +177,20 @@ private struct SybilPhoneSidebarRoot: View { HStack(spacing: 12) { toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") { + clearOpeningSelection() path = [.settings] } Spacer() toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") { + clearOpeningSelection() viewModel.startNewSearch() path = [.draftSearch] } toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) { + clearOpeningSelection() viewModel.startNewChat() path = [.draftChat] } @@ -198,9 +228,54 @@ private struct SybilPhoneSidebarRoot: View { .buttonStyle(.plain) .accessibilityLabel(accessibilityLabel) } + + private func clearOpeningSelection() { + openingRequestID = nil + openingSelection = nil + } + + private func open(_ selection: SidebarSelection) { + guard openingSelection != selection else { + return + } + + let requestID = UUID() + openingRequestID = requestID + openingSelection = selection + Task { + await viewModel.selectForNavigation(selection) + guard openingRequestID == requestID else { + return + } + path = [PhoneRoute.from(selection: selection)] + openingRequestID = nil + openingSelection = nil + } + } +} + +private struct SybilPhoneSidebarRowIsActiveKey: EnvironmentKey { + static let defaultValue = false +} + +private extension EnvironmentValues { + var sybilPhoneSidebarRowIsActive: Bool { + get { self[SybilPhoneSidebarRowIsActiveKey.self] } + set { self[SybilPhoneSidebarRowIsActiveKey.self] = newValue } + } +} + +private struct SybilPhoneSidebarRowButtonStyle: ButtonStyle { + var isHighlighted: Bool + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .environment(\.sybilPhoneSidebarRowIsActive, isHighlighted || configuration.isPressed) + } } private struct SybilPhoneSidebarRow: View { + @Environment(\.sybilPhoneSidebarRowIsActive) private var isHighlighted var item: SidebarItem var body: some View { @@ -208,14 +283,14 @@ private struct SybilPhoneSidebarRow: View { HStack(spacing: 8) { Image(systemName: item.kind == .chat ? "message" : "globe") .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(SybilTheme.textMuted) + .foregroundStyle(isHighlighted ? SybilTheme.accent : SybilTheme.textMuted) .frame(width: 22, height: 22) .background( RoundedRectangle(cornerRadius: 7) - .fill(SybilTheme.surface.opacity(0.72)) + .fill(isHighlighted ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72)) .overlay( RoundedRectangle(cornerRadius: 7) - .stroke(SybilTheme.border.opacity(0.72), lineWidth: 1) + .stroke(isHighlighted ? SybilTheme.accent.opacity(0.36) : SybilTheme.border.opacity(0.72), lineWidth: 1) ) ) @@ -246,11 +321,15 @@ private struct SybilPhoneSidebarRow: View { .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 12) - .fill(LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing)) + .fill( + isHighlighted + ? SybilTheme.selectedRowGradient + : LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing) + ) ) .overlay( RoundedRectangle(cornerRadius: 12) - .stroke(SybilTheme.border.opacity(0.72), lineWidth: 1) + .stroke(isHighlighted ? SybilTheme.primary.opacity(0.55) : SybilTheme.border.opacity(0.72), lineWidth: 1) ) } } @@ -285,8 +364,14 @@ private struct SybilPhoneDestinationView: View { private func applyRoute() { switch route { case let .chat(chatID): + guard viewModel.draftKind != nil || viewModel.selectedItem != .chat(chatID) else { + return + } viewModel.select(.chat(chatID)) case let .search(searchID): + guard viewModel.draftKind != nil || viewModel.selectedItem != .search(searchID) else { + return + } viewModel.select(.search(searchID)) case .draftChat: viewModel.startNewChat() diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift index 8cdafa7..12858e9 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift @@ -42,6 +42,22 @@ private struct PendingChatState { var messages: [Message] } +private enum ActiveSendContext: Equatable { + case draftChat(UUID) + case chat(String) + case draftSearch(UUID) + case search(String) + + var isSearch: Bool { + switch self { + case .draftSearch, .search: + return true + case .draftChat, .chat: + return false + } + } +} + private actor CompletionStreamStatus { private var streamError: String? @@ -99,6 +115,8 @@ final class SybilViewModel { @ObservationIgnored private var hasBootstrapped = false private var pendingChatState: PendingChatState? + private var activeSendContext: ActiveSendContext? + private var draftIdentity = UUID() @ObservationIgnored private var selectionTask: Task? @ObservationIgnored @@ -157,7 +175,7 @@ final class SybilViewModel { switch selectedItem { case .chat: - if let selectedChat { + if let selectedChat = currentSelectedChat { return chatTitle(title: selectedChat.title, messages: selectedChat.messages) } if let summary = selectedChatSummary { @@ -166,7 +184,7 @@ final class SybilViewModel { return "Chat" case .search: - if let selectedSearch { + if let selectedSearch = currentSelectedSearch { return searchTitle(title: selectedSearch.title, query: selectedSearch.query) } if let summary = selectedSearchSummary { @@ -233,7 +251,7 @@ final class SybilViewModel { } var displayedMessages: [Message] { - let canonical = displayableMessages(selectedChat?.messages ?? []) + let canonical = displayableMessages(currentSelectedChat?.messages ?? []) guard let pending = pendingChatState else { return canonical } @@ -252,6 +270,40 @@ final class SybilViewModel { return canonical } + var displayedSearch: SearchDetail? { + currentSelectedSearch + } + + var isSendingVisibleChat: Bool { + guard isSending, pendingChatState != nil else { + return false + } + + switch activeSendContext { + case let .draftChat(identity): + return draftKind == .chat && identity == draftIdentity + case let .chat(chatID): + return selectedItem == .chat(chatID) + case .draftSearch, .search, nil: + return false + } + } + + var isRunningVisibleSearch: Bool { + guard isSending else { + return false + } + + switch activeSendContext { + case let .draftSearch(identity): + return draftKind == .search && identity == draftIdentity + case let .search(searchID): + return selectedItem == .search(searchID) + case .draftChat, .chat, nil: + return false + } + } + var sidebarItems: [SidebarItem] { let chatItems: [SidebarItem] = chats.map { chat in let initiatedLabel: String? @@ -324,7 +376,10 @@ final class SybilViewModel { isCheckingSession = true authError = nil errorMessage = nil + resetSelectionLoading() pendingChatState = nil + activeSendContext = nil + draftIdentity = UUID() composerAttachments = [] settings.persist() @@ -396,10 +451,13 @@ final class SybilViewModel { func startNewChat() { SybilLog.debug(SybilLog.ui, "Starting draft chat") + resetSelectionLoading() + draftIdentity = UUID() draftKind = .chat selectedItem = nil selectedChat = nil selectedSearch = nil + pendingChatState = nil errorMessage = nil composer = "" composerAttachments = [] @@ -407,10 +465,13 @@ final class SybilViewModel { func startNewSearch() { SybilLog.debug(SybilLog.ui, "Starting draft search") + resetSelectionLoading() + draftIdentity = UUID() draftKind = .search selectedItem = nil selectedChat = nil selectedSearch = nil + pendingChatState = nil errorMessage = nil composer = "" composerAttachments = [] @@ -418,33 +479,72 @@ final class SybilViewModel { func openSettings() { SybilLog.debug(SybilLog.ui, "Opening settings") + resetSelectionLoading() draftKind = nil selectedItem = .settings selectedChat = nil selectedSearch = nil + pendingChatState = nil errorMessage = nil composerAttachments = [] } func select(_ selection: SidebarSelection) { - SybilLog.debug(SybilLog.ui, "Selecting \(selection.id)") - draftKind = nil - selectedItem = selection - errorMessage = nil - if case .search = selection { - composerAttachments = [] - } + _ = beginSelecting(selection) + } - if case .settings = selection { - selectedChat = nil - selectedSearch = nil + func selectForNavigation(_ selection: SidebarSelection, preloadTimeout: Duration = .seconds(3)) async { + guard beginSelecting(selection) != nil else { return } - selectionTask?.cancel() - selectionTask = Task { [weak self] in - await self?.refreshSelectionIfNeeded() + await waitForSelectionLoad(timeout: preloadTimeout) + } + + @discardableResult + private func beginSelecting(_ selection: SidebarSelection) -> Task? { + SybilLog.debug(SybilLog.ui, "Selecting \(selection.id)") + + if draftKind == nil, selectedItem == selection { + errorMessage = nil + if case .search = selection { + composerAttachments = [] + } + + if needsSelectionLoad(selection) { + return startSelectionRefreshTask() + } + + return selectionTask } + + resetSelectionLoading() + draftKind = nil + selectedItem = selection + errorMessage = nil + + switch selection { + case let .chat(chatID): + if selectedChat?.id != chatID { + selectedChat = nil + } + selectedSearch = nil + + case let .search(searchID): + if selectedSearch?.id != searchID { + selectedSearch = nil + } + selectedChat = nil + composerAttachments = [] + + case .settings: + selectedChat = nil + selectedSearch = nil + pendingChatState = nil + return nil + } + + return startSelectionRefreshTask() } func selectPreviousSidebarItem() { @@ -565,12 +665,13 @@ final class SybilViewModel { func sendComposer() async { let content = composer.trimmingCharacters(in: .whitespacesAndNewlines) let attachments = composerAttachments + let sendContext = currentSendContext guard !isSending else { return } - if isSearchMode { + if sendContext.isSearch { guard !content.isEmpty else { return } } else if content.isEmpty && attachments.isEmpty { return @@ -579,10 +680,11 @@ final class SybilViewModel { composer = "" composerAttachments = [] errorMessage = nil + activeSendContext = sendContext isSending = true do { - if isSearchMode { + if sendContext.isSearch { SybilLog.info(SybilLog.ui, "Sending search query") try await sendSearch(query: content) } else { @@ -590,35 +692,43 @@ final class SybilViewModel { try await sendChat(content: content, attachments: attachments) } } catch { - errorMessage = normalizeAPIError(error) + let shouldSurfaceError = isSendContextVisible(sendContext) || (activeSendContext.map { isSendContextVisible($0) } ?? false) + if shouldSurfaceError { + errorMessage = normalizeAPIError(error) + } SybilLog.error(SybilLog.ui, "Send failed", error: error) - if case let .chat(chatID) = selectedItem { + if shouldSurfaceError, case let .chat(chatID) = selectedItem { do { let chat = try await client().getChat(chatID: chatID) - selectedChat = chat + if selectedItem == .chat(chatID), draftKind == nil { + selectedChat = chat + } } catch { SybilLog.error(SybilLog.ui, "Fallback chat refresh after failure failed", error: error) } } - if case let .search(searchID) = selectedItem { + if shouldSurfaceError, case let .search(searchID) = selectedItem { do { let search = try await client().getSearch(searchID: searchID) - selectedSearch = search + if selectedItem == .search(searchID), draftKind == nil { + selectedSearch = search + } } catch { SybilLog.error(SybilLog.ui, "Fallback search refresh after failure failed", error: error) } } - if !isSearchMode { + if !sendContext.isSearch, shouldSurfaceError { composer = content composerAttachments = attachments - pendingChatState = nil } + pendingChatState = nil } isSending = false + activeSendContext = nil } func appendComposerAttachments(_ attachments: [ChatAttachment]) throws { @@ -644,16 +754,25 @@ final class SybilViewModel { } func startChatFromSelectedSearch() async { - guard let search = selectedSearch, !isCreatingSearchChat, !isSending else { + guard let search = currentSelectedSearch, !isCreatingSearchChat, !isSending else { return } + let sourceSelection = SidebarSelection.search(search.id) isCreatingSearchChat = true errorMessage = nil do { let client = try client() let chat = try await client.createChatFromSearch(searchID: search.id, title: nil) + + guard selectedItem == sourceSelection, draftKind == nil else { + chats.removeAll(where: { $0.id == chat.id }) + chats.insert(chat, at: 0) + isCreatingSearchChat = false + return + } + draftKind = nil pendingChatState = nil composer = "" @@ -767,6 +886,11 @@ final class SybilViewModel { ) errorMessage = nil + if draftKind != nil { + isLoadingCollections = false + return + } + if case .settings = selectedItem { isLoadingCollections = false return @@ -797,26 +921,68 @@ final class SybilViewModel { isLoadingCollections = false } + private func resetSelectionLoading() { + selectionTask?.cancel() + selectionTask = nil + isLoadingSelection = false + } + + private func startSelectionRefreshTask() -> Task { + isLoadingSelection = true + let task = Task { [weak self] in + guard let self else { + return + } + await self.refreshSelectionIfNeeded() + } + selectionTask = task + return task + } + + private func waitForSelectionLoad(timeout: Duration) async { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + + while isLoadingSelection, clock.now < deadline { + try? await Task.sleep(for: .milliseconds(10)) + } + } + + private func needsSelectionLoad(_ selection: SidebarSelection) -> Bool { + switch selection { + case let .chat(chatID): + return selectedChat?.id != chatID + case let .search(searchID): + return selectedSearch?.id != searchID + case .settings: + return false + } + } + private func refreshSelectionIfNeeded() async { - guard let selectedItem else { + guard let target = selectedItem else { selectedChat = nil selectedSearch = nil + isLoadingSelection = false return } - guard case .settings = selectedItem else { + guard case .settings = target else { isLoadingSelection = true do { let client = try client() - switch selectedItem { + switch target { case let .chat(chatID): SybilLog.debug(SybilLog.app, "Refreshing chat \(chatID)") - selectedChat = try await client.getChat(chatID: chatID) + let chat = try await client.getChat(chatID: chatID) + guard selectedItem == target, draftKind == nil else { + return + } + selectedChat = chat selectedSearch = nil - if let detail = selectedChat, - let provider = detail.lastUsedProvider, - let model = detail.lastUsedModel, + if let provider = chat.lastUsedProvider, + let model = chat.lastUsedModel, !model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { self.provider = provider self.model = model @@ -824,7 +990,11 @@ final class SybilViewModel { case let .search(searchID): SybilLog.debug(SybilLog.app, "Refreshing search \(searchID)") - selectedSearch = try await client.getSearch(searchID: searchID) + let search = try await client.getSearch(searchID: searchID) + guard selectedItem == target, draftKind == nil else { + return + } + selectedSearch = search selectedChat = nil case .settings: @@ -833,20 +1003,24 @@ final class SybilViewModel { errorMessage = nil } catch { if isCancellation(error) { - SybilLog.debug(SybilLog.app, "Selection refresh cancelled for \(selectedItem.id)") + SybilLog.debug(SybilLog.app, "Selection refresh cancelled for \(target.id)") } else if shouldSuppressInactiveTransportError(error) { SybilLog.info(SybilLog.app, "Suppressing selection refresh transport interruption while app is inactive") - } else { + } else if selectedItem == target, draftKind == nil { errorMessage = normalizeAPIError(error) SybilLog.error(SybilLog.app, "Selection refresh failed", error: error) } } - isLoadingSelection = false + if selectedItem == target, draftKind == nil { + isLoadingSelection = false + selectionTask = nil + } return } selectedChat = nil selectedSearch = nil + isLoadingSelection = false } private func sendChat(content: String, attachments: [ChatAttachment]) async throws { @@ -869,7 +1043,7 @@ final class SybilViewModel { pendingChatState = PendingChatState( chatID: currentChatID, - messages: (selectedChat?.messages ?? []) + [optimisticUser, optimisticAssistant] + messages: (currentSelectedChat?.messages ?? []) + [optimisticUser, optimisticAssistant] ) let client = try client() @@ -878,24 +1052,29 @@ final class SybilViewModel { if chatID == nil { let created = try await client.createChat(title: nil) chatID = created.id - draftKind = nil - selectedItem = .chat(created.id) + let shouldShowCreatedChat = activeSendContext.map { isSendContextVisible($0) } ?? true + activeSendContext = .chat(created.id) chats.removeAll(where: { $0.id == created.id }) chats.insert(created, at: 0) - selectedChat = ChatDetail( - id: created.id, - title: created.title, - createdAt: created.createdAt, - updatedAt: created.updatedAt, - initiatedProvider: created.initiatedProvider, - initiatedModel: created.initiatedModel, - lastUsedProvider: created.lastUsedProvider, - lastUsedModel: created.lastUsedModel, - messages: [] - ) - selectedSearch = nil + if shouldShowCreatedChat { + draftKind = nil + selectedItem = .chat(created.id) + + selectedChat = ChatDetail( + id: created.id, + title: created.title, + createdAt: created.createdAt, + updatedAt: created.updatedAt, + initiatedProvider: created.initiatedProvider, + initiatedModel: created.initiatedModel, + lastUsedProvider: created.lastUsedProvider, + lastUsedModel: created.lastUsedModel, + messages: [] + ) + selectedSearch = nil + } SybilLog.info(SybilLog.app, "Created chat \(created.id)") } @@ -907,7 +1086,7 @@ final class SybilViewModel { pendingChatState?.chatID = chatID let baseChat: ChatDetail - if let selectedChat, selectedChat.id == chatID { + if let selectedChat = currentSelectedChat, selectedChat.id == chatID { baseChat = selectedChat } else { baseChat = try await client.getChat(chatID: chatID) @@ -929,7 +1108,7 @@ final class SybilViewModel { let streamLifecycleGeneration = appLifecycleGeneration let streamStartedWhileInactive = !isAppActive - if isUntitledChat(chatID: chatID, detail: selectedChat) { + if isUntitledChat(chatID: chatID, detail: currentSelectedChat) { Task { [weak self] in guard let self else { return } do { @@ -1001,9 +1180,25 @@ final class SybilViewModel { return } - await refreshCollections(preferredSelection: .chat(chatID)) + let sentChatSelection = SidebarSelection.chat(chatID) + let shouldKeepSentChatSelected = selectedItem == sentChatSelection && draftKind == nil + await refreshCollections( + preferredSelection: shouldKeepSentChatSelected ? sentChatSelection : selectedItem, + refreshSelection: false + ) + + guard selectedItem == sentChatSelection, draftKind == nil else { + pendingChatState = nil + return + } + do { - selectedChat = try await client.getChat(chatID: chatID) + let refreshedChat = try await client.getChat(chatID: chatID) + guard selectedItem == sentChatSelection, draftKind == nil else { + pendingChatState = nil + return + } + selectedChat = refreshedChat } catch { if shouldSuppressLifecycleTransportError( error, @@ -1057,12 +1252,17 @@ final class SybilViewModel { if searchID == nil { let created = try await client.createSearch(title: String(query.prefix(80)), query: query) searchID = created.id - draftKind = nil - selectedItem = .search(created.id) + let shouldShowCreatedSearch = activeSendContext.map { isSendContextVisible($0) } ?? true + activeSendContext = .search(created.id) searches.removeAll(where: { $0.id == created.id }) searches.insert(created, at: 0) + if shouldShowCreatedSearch { + draftKind = nil + selectedItem = .search(created.id) + } + SybilLog.info(SybilLog.app, "Created search \(created.id)") } @@ -1071,21 +1271,23 @@ final class SybilViewModel { } let now = Date() - selectedSearch = SearchDetail( - id: searchID, - title: String(query.prefix(80)), - query: query, - createdAt: selectedSearch?.createdAt ?? now, - updatedAt: now, - requestId: nil, - latencyMs: nil, - error: nil, - answerText: nil, - answerRequestId: nil, - answerCitations: nil, - answerError: nil, - results: [] - ) + if selectedItem == .search(searchID), draftKind == nil { + selectedSearch = SearchDetail( + id: searchID, + title: String(query.prefix(80)), + query: query, + createdAt: currentSelectedSearch?.createdAt ?? now, + updatedAt: now, + requestId: nil, + latencyMs: nil, + error: nil, + answerText: nil, + answerRequestId: nil, + answerCitations: nil, + answerError: nil, + results: [] + ) + } let streamStatus = SearchStreamStatus() let streamLifecycleGeneration = appLifecycleGeneration @@ -1123,7 +1325,12 @@ final class SybilViewModel { return } - await refreshCollections(preferredSelection: .search(searchID)) + let sentSearchSelection = SidebarSelection.search(searchID) + let shouldKeepSentSearchSelected = selectedItem == sentSearchSelection && draftKind == nil + await refreshCollections( + preferredSelection: shouldKeepSentSearchSelected ? sentSearchSelection : selectedItem, + refreshSelection: false + ) } private func applySearchEvent( @@ -1131,8 +1338,10 @@ final class SybilViewModel { searchID: String, streamStatus: SearchStreamStatus ) async { - guard let current = selectedSearch, current.id == searchID else { - if case let .done(payload) = event { + guard let current = currentSelectedSearch, current.id == searchID else { + if case let .done(payload) = event, + selectedItem == .search(searchID), + draftKind == nil { selectedSearch = payload.search } return @@ -1239,6 +1448,22 @@ final class SybilViewModel { return nil } + private var currentSelectedChat: ChatDetail? { + guard case let .chat(chatID) = selectedItem, + selectedChat?.id == chatID else { + return nil + } + return selectedChat + } + + private var currentSelectedSearch: SearchDetail? { + guard case let .search(searchID) = selectedItem, + selectedSearch?.id == searchID else { + return nil + } + return selectedSearch + } + private var currentSearchID: String? { if draftKind == .search { return nil @@ -1249,6 +1474,33 @@ final class SybilViewModel { return nil } + private var currentSendContext: ActiveSendContext { + if isSearchMode { + if let searchID = currentSearchID { + return .search(searchID) + } + return .draftSearch(draftIdentity) + } + + if let chatID = currentChatID { + return .chat(chatID) + } + return .draftChat(draftIdentity) + } + + private func isSendContextVisible(_ context: ActiveSendContext) -> Bool { + switch context { + case let .draftChat(identity): + return draftKind == .chat && draftIdentity == identity + case let .chat(chatID): + return selectedItem == .chat(chatID) + case let .draftSearch(identity): + return draftKind == .search && draftIdentity == identity + case let .search(searchID): + return selectedItem == .search(searchID) + } + } + private func hasSelection(_ selection: SidebarSelection, chats: [ChatSummary], searches: [SearchSummary]) -> Bool { switch selection { case let .chat(chatID): diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift index 5822cbf..b55dafc 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift @@ -155,9 +155,9 @@ struct SybilWorkspaceView: View { SybilSettingsView(viewModel: viewModel) } else if viewModel.isSearchMode { SybilSearchResultsView( - search: viewModel.selectedSearch, + search: viewModel.displayedSearch, isLoading: viewModel.isLoadingSelection, - isRunning: viewModel.isSending, + isRunning: viewModel.isRunningVisibleSearch, isStartingChat: viewModel.isCreatingSearchChat, topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0, bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0 @@ -170,7 +170,7 @@ struct SybilWorkspaceView: View { SybilChatTranscriptView( messages: viewModel.displayedMessages, isLoading: viewModel.isLoadingSelection, - isSending: viewModel.isSending, + isSending: viewModel.isSendingVisibleChat, topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0, bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0 ) diff --git a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift index 748161a..9d6bc86 100644 --- a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift +++ b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift @@ -17,8 +17,11 @@ private actor MockSybilClient: SybilAPIClienting { private let searchesResponse: [SearchSummary] private let chatDetails: [String: ChatDetail] private let searchDetails: [String: SearchDetail] + private let createChatResponse: ChatSummary? private var snapshot = MockClientCallSnapshot() + private var getChatDelayNanoseconds: UInt64 = 0 + private var getSearchDelayNanoseconds: UInt64 = 0 private var completionStreamNetworkErrorMessage: String? private var completionStreamDelayNanoseconds: UInt64 = 0 private var searchStreamNetworkErrorMessage: String? @@ -28,12 +31,14 @@ private actor MockSybilClient: SybilAPIClienting { chatsResponse: [ChatSummary] = [], searchesResponse: [SearchSummary] = [], chatDetails: [String: ChatDetail] = [:], - searchDetails: [String: SearchDetail] = [:] + searchDetails: [String: SearchDetail] = [:], + createChatResponse: ChatSummary? = nil ) { self.chatsResponse = chatsResponse self.searchesResponse = searchesResponse self.chatDetails = chatDetails self.searchDetails = searchDetails + self.createChatResponse = createChatResponse } func currentSnapshot() -> MockClientCallSnapshot { @@ -45,6 +50,14 @@ private actor MockSybilClient: SybilAPIClienting { completionStreamDelayNanoseconds = delayNanoseconds } + func setGetChatDelay(_ delayNanoseconds: UInt64) { + getChatDelayNanoseconds = delayNanoseconds + } + + func setGetSearchDelay(_ delayNanoseconds: UInt64) { + getSearchDelayNanoseconds = delayNanoseconds + } + func setSearchStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) { searchStreamNetworkErrorMessage = message searchStreamDelayNanoseconds = delayNanoseconds @@ -60,11 +73,17 @@ private actor MockSybilClient: SybilAPIClienting { } func createChat(title: String?) async throws -> ChatSummary { + if let createChatResponse { + return createChatResponse + } throw UnexpectedClientCall() } func getChat(chatID: String) async throws -> ChatDetail { snapshot.getChat += 1 + if getChatDelayNanoseconds > 0 { + try await Task.sleep(nanoseconds: getChatDelayNanoseconds) + } guard let detail = chatDetails[chatID] else { throw UnexpectedClientCall() } @@ -90,6 +109,9 @@ private actor MockSybilClient: SybilAPIClienting { func getSearch(searchID: String) async throws -> SearchDetail { snapshot.getSearch += 1 + if getSearchDelayNanoseconds > 0 { + try await Task.sleep(nanoseconds: getSearchDelayNanoseconds) + } guard let detail = searchDetails[searchID] else { throw UnexpectedClientCall() } @@ -293,6 +315,100 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD #expect(viewModel.selectedSearch?.answerText == "fresh answer") } +@MainActor +@Test func selectingChatClearsStaleTranscriptUntilNewDetailLoads() async throws { + let date = Date(timeIntervalSince1970: 1_700_000_210) + let staleDetail = makeChatDetail(id: "chat-old", date: date, body: "stale transcript") + let freshDetail = makeChatDetail(id: "chat-new", date: date, body: "fresh transcript") + let client = MockSybilClient(chatDetails: ["chat-new": freshDetail]) + await client.setGetChatDelay(50_000_000) + let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } + viewModel.isAuthenticated = true + viewModel.isCheckingSession = false + viewModel.selectedItem = .chat("chat-old") + viewModel.selectedChat = staleDetail + + viewModel.select(.chat("chat-new")) + + #expect(viewModel.displayedMessages.isEmpty) + #expect(viewModel.isLoadingSelection) + + try await Task.sleep(nanoseconds: 90_000_000) + + #expect(viewModel.displayedMessages.first?.content == "fresh transcript") + #expect(!viewModel.isLoadingSelection) +} + +@MainActor +@Test func navigationSelectionWaitsForFastTranscriptLoad() async throws { + let date = Date(timeIntervalSince1970: 1_700_000_220) + let detail = makeChatDetail(id: "chat-fast", date: date, body: "loaded before push") + let client = MockSybilClient(chatDetails: ["chat-fast": detail]) + await client.setGetChatDelay(20_000_000) + let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } + viewModel.isAuthenticated = true + viewModel.isCheckingSession = false + + await viewModel.selectForNavigation(.chat("chat-fast"), preloadTimeout: .milliseconds(500)) + + #expect(viewModel.selectedItem == .chat("chat-fast")) + #expect(viewModel.displayedMessages.first?.content == "loaded before push") + #expect(!viewModel.isLoadingSelection) +} + +@MainActor +@Test func navigationSelectionTimesOutAndKeepsLoadingTranscript() async throws { + let date = Date(timeIntervalSince1970: 1_700_000_230) + let detail = makeChatDetail(id: "chat-slow", date: date, body: "loaded after push") + let client = MockSybilClient(chatDetails: ["chat-slow": detail]) + await client.setGetChatDelay(100_000_000) + let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } + viewModel.isAuthenticated = true + viewModel.isCheckingSession = false + + await viewModel.selectForNavigation(.chat("chat-slow"), preloadTimeout: .milliseconds(10)) + + #expect(viewModel.selectedItem == .chat("chat-slow")) + #expect(viewModel.displayedMessages.isEmpty) + #expect(viewModel.isLoadingSelection) + + try await Task.sleep(nanoseconds: 150_000_000) + + #expect(viewModel.displayedMessages.first?.content == "loaded after push") + #expect(!viewModel.isLoadingSelection) +} + +@MainActor +@Test func newDraftChatDoesNotShowTypingStateFromPreviousSend() async throws { + let date = Date(timeIntervalSince1970: 1_700_000_240) + let detail = makeChatDetail(id: "chat-typing", date: date, body: "existing transcript") + let client = MockSybilClient(chatDetails: ["chat-typing": detail]) + await client.setCompletionStreamNetworkError( + "Network error -1005 while requesting POST: The network connection was lost.", + delayNanoseconds: 50_000_000 + ) + let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } + viewModel.isAuthenticated = true + viewModel.isCheckingSession = false + viewModel.selectedItem = .chat("chat-typing") + viewModel.selectedChat = detail + viewModel.composer = "continue" + + let sendTask = Task { + await viewModel.sendComposer() + } + try await Task.sleep(nanoseconds: 10_000_000) + + #expect(viewModel.isSendingVisibleChat) + + viewModel.startNewChat() + + #expect(viewModel.displayedMessages.isEmpty) + #expect(!viewModel.isSendingVisibleChat) + + await sendTask.value +} + @MainActor @Test func backgroundChatStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws { let date = Date(timeIntervalSince1970: 1_700_000_300)