Compare commits
3 Commits
cf9832ca3b
...
4b0cc3fbf7
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b0cc3fbf7 | |||
| 2da73f802c | |||
| 4ad36d9bf6 |
@@ -3,6 +3,8 @@ import SwiftUI
|
|||||||
public struct SplitView: View {
|
public struct SplitView: View {
|
||||||
@State private var viewModel = SybilViewModel()
|
@State private var viewModel = SybilViewModel()
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
@State private var shouldRefreshOnForeground = false
|
||||||
|
|
||||||
@MainActor public init() {
|
@MainActor public init() {
|
||||||
SybilFontRegistry.registerIfNeeded()
|
SybilFontRegistry.registerIfNeeded()
|
||||||
@@ -38,5 +40,26 @@ public struct SplitView: View {
|
|||||||
.task {
|
.task {
|
||||||
await viewModel.bootstrap()
|
await viewModel.bootstrap()
|
||||||
}
|
}
|
||||||
|
.onChange(of: scenePhase) { _, nextPhase in
|
||||||
|
switch nextPhase {
|
||||||
|
case .background:
|
||||||
|
shouldRefreshOnForeground = true
|
||||||
|
case .active:
|
||||||
|
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shouldRefreshOnForeground = false
|
||||||
|
Task {
|
||||||
|
await viewModel.refreshVisibleContent(
|
||||||
|
refreshCollections: true,
|
||||||
|
refreshSelection: viewModel.hasRefreshableSelection
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case .inactive:
|
||||||
|
break
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ struct AnyEncodable: Encodable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actor SybilAPIClient {
|
actor SybilAPIClient: SybilAPIClienting {
|
||||||
private let configuration: APIConfiguration
|
private let configuration: APIConfiguration
|
||||||
private let session: URLSession
|
private let session: URLSession
|
||||||
|
|
||||||
|
|||||||
25
ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift
Normal file
25
ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol SybilAPIClienting: Sendable {
|
||||||
|
func verifySession() async throws -> AuthSession
|
||||||
|
func listChats() async throws -> [ChatSummary]
|
||||||
|
func createChat(title: String?) async throws -> ChatSummary
|
||||||
|
func getChat(chatID: String) async throws -> ChatDetail
|
||||||
|
func deleteChat(chatID: String) async throws
|
||||||
|
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
|
||||||
|
func listSearches() async throws -> [SearchSummary]
|
||||||
|
func createSearch(title: String?, query: String?) async throws -> SearchSummary
|
||||||
|
func getSearch(searchID: String) async throws -> SearchDetail
|
||||||
|
func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary
|
||||||
|
func deleteSearch(searchID: String) async throws
|
||||||
|
func listModels() async throws -> ModelCatalogResponse
|
||||||
|
func runCompletionStream(
|
||||||
|
body: CompletionStreamRequest,
|
||||||
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
|
) async throws
|
||||||
|
func runSearchStream(
|
||||||
|
searchID: String,
|
||||||
|
body: SearchRunRequest,
|
||||||
|
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||||
|
) async throws
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SybilBackgroundTaskAssertion {
|
||||||
|
private let name: String
|
||||||
|
private var identifier: UIBackgroundTaskIdentifier = .invalid
|
||||||
|
|
||||||
|
init?(name: String, onExpiration: @escaping @MainActor () -> Void = {}) {
|
||||||
|
self.name = name
|
||||||
|
identifier = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self else { return }
|
||||||
|
SybilLog.warning(SybilLog.app, "Background task expired: \(self.name)")
|
||||||
|
onExpiration()
|
||||||
|
self.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard identifier != .invalid else {
|
||||||
|
SybilLog.warning(SybilLog.app, "Failed to acquire background task: \(name)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
SybilLog.debug(SybilLog.app, "Acquired background task: \(name)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func end() {
|
||||||
|
guard identifier != .invalid else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
UIApplication.shared.endBackgroundTask(identifier)
|
||||||
|
identifier = .invalid
|
||||||
|
SybilLog.debug(SybilLog.app, "Ended background task: \(name)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ struct SybilChatTranscriptView: View {
|
|||||||
var messages: [Message]
|
var messages: [Message]
|
||||||
var isLoading: Bool
|
var isLoading: Bool
|
||||||
var isSending: Bool
|
var isSending: Bool
|
||||||
|
@State private var hasHandledInitialTranscriptScroll = false
|
||||||
|
|
||||||
private var hasPendingAssistant: Bool {
|
private var hasPendingAssistant: Bool {
|
||||||
messages.contains { message in
|
messages.contains { message in
|
||||||
@@ -52,20 +53,27 @@ struct SybilChatTranscriptView: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
|
scrollToBottom(with: proxy, animated: false)
|
||||||
}
|
}
|
||||||
.onChange(of: messages.map(\.id)) { _, _ in
|
.onChange(of: messages.map(\.id)) { _, _ in
|
||||||
withAnimation(.easeOut(duration: 0.22)) {
|
scrollToBottom(with: proxy, animated: hasHandledInitialTranscriptScroll && !isLoading)
|
||||||
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
|
hasHandledInitialTranscriptScroll = true
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onChange(of: isSending) { _, _ in
|
.onChange(of: isSending) { _, _ in
|
||||||
withAnimation(.easeOut(duration: 0.22)) {
|
scrollToBottom(with: proxy, animated: hasHandledInitialTranscriptScroll)
|
||||||
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func scrollToBottom(with proxy: ScrollViewProxy, animated: Bool) {
|
||||||
|
if animated {
|
||||||
|
withAnimation(.easeOut(duration: 0.22)) {
|
||||||
|
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct MessageBubble: View {
|
private struct MessageBubble: View {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ enum PhoneRoute: Hashable {
|
|||||||
struct SybilPhoneShellView: View {
|
struct SybilPhoneShellView: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
@State private var path: [PhoneRoute] = []
|
@State private var path: [PhoneRoute] = []
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
@State private var shouldRefreshOnForeground = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack(path: $path) {
|
NavigationStack(path: $path) {
|
||||||
@@ -39,6 +41,27 @@ struct SybilPhoneShellView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(SybilTheme.primary)
|
.tint(SybilTheme.primary)
|
||||||
|
.onChange(of: scenePhase) { _, nextPhase in
|
||||||
|
switch nextPhase {
|
||||||
|
case .background:
|
||||||
|
shouldRefreshOnForeground = true
|
||||||
|
case .active:
|
||||||
|
guard shouldRefreshOnForeground else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shouldRefreshOnForeground = false
|
||||||
|
Task {
|
||||||
|
await viewModel.refreshVisibleContent(
|
||||||
|
refreshCollections: path.isEmpty,
|
||||||
|
refreshSelection: !path.isEmpty && viewModel.hasRefreshableSelection
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case .inactive:
|
||||||
|
break
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,9 +96,15 @@ final class SybilViewModel {
|
|||||||
var modelCatalog: [Provider: ProviderModelInfo] = [:]
|
var modelCatalog: [Provider: ProviderModelInfo] = [:]
|
||||||
var model: String
|
var model: String
|
||||||
|
|
||||||
|
@ObservationIgnored
|
||||||
private var hasBootstrapped = false
|
private var hasBootstrapped = false
|
||||||
private var pendingChatState: PendingChatState?
|
private var pendingChatState: PendingChatState?
|
||||||
|
@ObservationIgnored
|
||||||
private var selectionTask: Task<Void, Never>?
|
private var selectionTask: Task<Void, Never>?
|
||||||
|
@ObservationIgnored
|
||||||
|
private var chatBackgroundTask: SybilBackgroundTaskAssertion?
|
||||||
|
@ObservationIgnored
|
||||||
|
private let clientFactory: (APIConfiguration) -> any SybilAPIClienting
|
||||||
|
|
||||||
private let fallbackModels: [Provider: [String]] = [
|
private let fallbackModels: [Provider: [String]] = [
|
||||||
.openai: ["gpt-4.1-mini"],
|
.openai: ["gpt-4.1-mini"],
|
||||||
@@ -106,8 +112,14 @@ final class SybilViewModel {
|
|||||||
.xai: ["grok-3-mini"]
|
.xai: ["grok-3-mini"]
|
||||||
]
|
]
|
||||||
|
|
||||||
init(settings: SybilSettingsStore = SybilSettingsStore()) {
|
init(
|
||||||
|
settings: SybilSettingsStore = SybilSettingsStore(),
|
||||||
|
clientFactory: @escaping (APIConfiguration) -> any SybilAPIClienting = { configuration in
|
||||||
|
SybilAPIClient(configuration: configuration)
|
||||||
|
}
|
||||||
|
) {
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
|
self.clientFactory = clientFactory
|
||||||
self.provider = settings.preferredProvider
|
self.provider = settings.preferredProvider
|
||||||
self.model = settings.preferredModelByProvider[settings.preferredProvider] ?? "gpt-4.1-mini"
|
self.model = settings.preferredModelByProvider[settings.preferredProvider] ?? "gpt-4.1-mini"
|
||||||
}
|
}
|
||||||
@@ -281,6 +293,19 @@ final class SybilViewModel {
|
|||||||
return searches.first(where: { $0.id == searchID })
|
return searches.first(where: { $0.id == searchID })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var hasRefreshableSelection: Bool {
|
||||||
|
guard draftKind == nil, let selectedItem else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch selectedItem {
|
||||||
|
case .chat, .search:
|
||||||
|
return true
|
||||||
|
case .settings:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func bootstrap() async {
|
func bootstrap() async {
|
||||||
guard !hasBootstrapped else {
|
guard !hasBootstrapped else {
|
||||||
return
|
return
|
||||||
@@ -449,6 +474,30 @@ final class SybilViewModel {
|
|||||||
await reconnect()
|
await reconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func refreshVisibleContent(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
|
||||||
|
guard isAuthenticated, !isCheckingSession else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard shouldRefreshCollections || shouldRefreshSelection else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SybilLog.info(
|
||||||
|
SybilLog.ui,
|
||||||
|
"Foreground refresh requested (collections=\(shouldRefreshCollections), selection=\(shouldRefreshSelection))"
|
||||||
|
)
|
||||||
|
|
||||||
|
if shouldRefreshCollections {
|
||||||
|
await refreshCollections(preferredSelection: selectedItem, refreshSelection: shouldRefreshSelection)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldRefreshSelection {
|
||||||
|
await refreshSelectionIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func sendComposer() async {
|
func sendComposer() async {
|
||||||
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let attachments = composerAttachments
|
let attachments = composerAttachments
|
||||||
@@ -540,7 +589,7 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let client = try client()
|
let client = try client()
|
||||||
let chat = try await client.createChatFromSearch(searchID: search.id)
|
let chat = try await client.createChatFromSearch(searchID: search.id, title: nil)
|
||||||
draftKind = nil
|
draftKind = nil
|
||||||
pendingChatState = nil
|
pendingChatState = nil
|
||||||
composer = ""
|
composer = ""
|
||||||
@@ -561,7 +610,7 @@ final class SybilViewModel {
|
|||||||
isCreatingSearchChat = false
|
isCreatingSearchChat = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadInitialData(using client: SybilAPIClient) async {
|
private func loadInitialData(using client: any SybilAPIClienting) async {
|
||||||
isLoadingCollections = true
|
isLoadingCollections = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
@@ -633,7 +682,10 @@ final class SybilViewModel {
|
|||||||
settings.persist()
|
settings.persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshCollections(preferredSelection: SidebarSelection?) async {
|
private func refreshCollections(
|
||||||
|
preferredSelection: SidebarSelection?,
|
||||||
|
refreshSelection: Bool = true
|
||||||
|
) async {
|
||||||
isLoadingCollections = true
|
isLoadingCollections = true
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -665,7 +717,7 @@ final class SybilViewModel {
|
|||||||
selectedItem = sidebarItems.first?.selection
|
selectedItem = sidebarItems.first?.selection
|
||||||
}
|
}
|
||||||
|
|
||||||
if selectedItem != nil {
|
if refreshSelection, selectedItem != nil {
|
||||||
await refreshSelectionIfNeeded()
|
await refreshSelectionIfNeeded()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -752,7 +804,7 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
var chatID = currentChatID
|
var chatID = currentChatID
|
||||||
if chatID == nil {
|
if chatID == nil {
|
||||||
let created = try await client.createChat()
|
let created = try await client.createChat(title: nil)
|
||||||
chatID = created.id
|
chatID = created.id
|
||||||
draftKind = nil
|
draftKind = nil
|
||||||
selectedItem = .chat(created.id)
|
selectedItem = .chat(created.id)
|
||||||
@@ -828,6 +880,15 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
chatBackgroundTask?.end()
|
||||||
|
chatBackgroundTask = SybilBackgroundTaskAssertion(name: "Sybil Chat Response") {
|
||||||
|
SybilLog.warning(SybilLog.app, "Chat response background time expired")
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
chatBackgroundTask?.end()
|
||||||
|
chatBackgroundTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
try await client.runCompletionStream(
|
try await client.runCompletionStream(
|
||||||
body: CompletionStreamRequest(
|
body: CompletionStreamRequest(
|
||||||
chatId: chatID,
|
chatId: chatID,
|
||||||
@@ -1171,7 +1232,7 @@ final class SybilViewModel {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func client() throws -> SybilAPIClient {
|
private func client() throws -> any SybilAPIClienting {
|
||||||
guard let baseURL = settings.normalizedAPIBaseURL else {
|
guard let baseURL = settings.normalizedAPIBaseURL else {
|
||||||
throw APIError.invalidBaseURL
|
throw APIError.invalidBaseURL
|
||||||
}
|
}
|
||||||
@@ -1181,8 +1242,8 @@ final class SybilViewModel {
|
|||||||
"Creating API client for \(baseURL.absoluteString) (token: \(settings.trimmedTokenOrNil == nil ? "none" : "set"))"
|
"Creating API client for \(baseURL.absoluteString) (token: \(settings.trimmedTokenOrNil == nil ? "none" : "set"))"
|
||||||
)
|
)
|
||||||
|
|
||||||
return SybilAPIClient(
|
return clientFactory(
|
||||||
configuration: APIConfiguration(
|
APIConfiguration(
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
authToken: settings.trimmedTokenOrNil
|
authToken: settings.trimmedTokenOrNil
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ struct SybilWorkspaceView: View {
|
|||||||
viewModel.errorMessage != nil
|
viewModel.errorMessage != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var transcriptScrollContextID: String {
|
||||||
|
if viewModel.draftKind == .chat {
|
||||||
|
return "draft-chat"
|
||||||
|
}
|
||||||
|
if case let .chat(chatID) = viewModel.selectedItem {
|
||||||
|
return "chat:\(chatID)"
|
||||||
|
}
|
||||||
|
return "chat:none"
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if showsHeader {
|
if showsHeader {
|
||||||
@@ -53,6 +63,7 @@ struct SybilWorkspaceView: View {
|
|||||||
isLoading: viewModel.isLoadingSelection,
|
isLoading: viewModel.isLoadingSelection,
|
||||||
isSending: viewModel.isSending
|
isSending: viewModel.isSending
|
||||||
)
|
)
|
||||||
|
.id(transcriptScrollContextID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|||||||
@@ -1,6 +1,186 @@
|
|||||||
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
@testable import Sybil
|
@testable import Sybil
|
||||||
|
|
||||||
|
private struct MockClientCallSnapshot: Sendable {
|
||||||
|
var listChats = 0
|
||||||
|
var listSearches = 0
|
||||||
|
var getChat = 0
|
||||||
|
var getSearch = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct UnexpectedClientCall: Error {}
|
||||||
|
|
||||||
|
private actor MockSybilClient: SybilAPIClienting {
|
||||||
|
private let chatsResponse: [ChatSummary]
|
||||||
|
private let searchesResponse: [SearchSummary]
|
||||||
|
private let chatDetails: [String: ChatDetail]
|
||||||
|
private let searchDetails: [String: SearchDetail]
|
||||||
|
|
||||||
|
private var snapshot = MockClientCallSnapshot()
|
||||||
|
|
||||||
|
init(
|
||||||
|
chatsResponse: [ChatSummary] = [],
|
||||||
|
searchesResponse: [SearchSummary] = [],
|
||||||
|
chatDetails: [String: ChatDetail] = [:],
|
||||||
|
searchDetails: [String: SearchDetail] = [:]
|
||||||
|
) {
|
||||||
|
self.chatsResponse = chatsResponse
|
||||||
|
self.searchesResponse = searchesResponse
|
||||||
|
self.chatDetails = chatDetails
|
||||||
|
self.searchDetails = searchDetails
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentSnapshot() -> MockClientCallSnapshot {
|
||||||
|
snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifySession() async throws -> AuthSession {
|
||||||
|
AuthSession(authenticated: true, mode: "open")
|
||||||
|
}
|
||||||
|
|
||||||
|
func listChats() async throws -> [ChatSummary] {
|
||||||
|
snapshot.listChats += 1
|
||||||
|
return chatsResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func createChat(title: String?) async throws -> ChatSummary {
|
||||||
|
throw UnexpectedClientCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getChat(chatID: String) async throws -> ChatDetail {
|
||||||
|
snapshot.getChat += 1
|
||||||
|
guard let detail = chatDetails[chatID] else {
|
||||||
|
throw UnexpectedClientCall()
|
||||||
|
}
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteChat(chatID: String) async throws {
|
||||||
|
throw UnexpectedClientCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary {
|
||||||
|
throw UnexpectedClientCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
func listSearches() async throws -> [SearchSummary] {
|
||||||
|
snapshot.listSearches += 1
|
||||||
|
return searchesResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSearch(title: String?, query: String?) async throws -> SearchSummary {
|
||||||
|
throw UnexpectedClientCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSearch(searchID: String) async throws -> SearchDetail {
|
||||||
|
snapshot.getSearch += 1
|
||||||
|
guard let detail = searchDetails[searchID] else {
|
||||||
|
throw UnexpectedClientCall()
|
||||||
|
}
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
|
||||||
|
func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary {
|
||||||
|
throw UnexpectedClientCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteSearch(searchID: String) async throws {
|
||||||
|
throw UnexpectedClientCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
func listModels() async throws -> ModelCatalogResponse {
|
||||||
|
ModelCatalogResponse(providers: [:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCompletionStream(
|
||||||
|
body: CompletionStreamRequest,
|
||||||
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
|
) async throws {
|
||||||
|
throw UnexpectedClientCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSearchStream(
|
||||||
|
searchID: String,
|
||||||
|
body: SearchRunRequest,
|
||||||
|
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||||
|
) async throws {
|
||||||
|
throw UnexpectedClientCall()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func testSettings(named name: String) -> SybilSettingsStore {
|
||||||
|
let defaults = UserDefaults(suiteName: name)!
|
||||||
|
defaults.removePersistentDomain(forName: name)
|
||||||
|
let settings = SybilSettingsStore(defaults: defaults)
|
||||||
|
settings.apiBaseURL = "http://127.0.0.1:8787"
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeChatSummary(id: String, date: Date) -> ChatSummary {
|
||||||
|
ChatSummary(
|
||||||
|
id: id,
|
||||||
|
title: "Chat \(id)",
|
||||||
|
createdAt: date,
|
||||||
|
updatedAt: date,
|
||||||
|
initiatedProvider: .openai,
|
||||||
|
initiatedModel: "gpt-4.1-mini",
|
||||||
|
lastUsedProvider: .openai,
|
||||||
|
lastUsedModel: "gpt-4.1-mini"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeChatDetail(id: String, date: Date, body: String) -> ChatDetail {
|
||||||
|
ChatDetail(
|
||||||
|
id: id,
|
||||||
|
title: "Chat \(id)",
|
||||||
|
createdAt: date,
|
||||||
|
updatedAt: date,
|
||||||
|
initiatedProvider: .openai,
|
||||||
|
initiatedModel: "gpt-4.1-mini",
|
||||||
|
lastUsedProvider: .openai,
|
||||||
|
lastUsedModel: "gpt-4.1-mini",
|
||||||
|
messages: [
|
||||||
|
Message(
|
||||||
|
id: "message-\(id)",
|
||||||
|
createdAt: date,
|
||||||
|
role: .assistant,
|
||||||
|
content: body,
|
||||||
|
name: nil
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeSearchSummary(id: String, date: Date) -> SearchSummary {
|
||||||
|
SearchSummary(
|
||||||
|
id: id,
|
||||||
|
title: "Search \(id)",
|
||||||
|
query: "query-\(id)",
|
||||||
|
createdAt: date,
|
||||||
|
updatedAt: date
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchDetail {
|
||||||
|
SearchDetail(
|
||||||
|
id: id,
|
||||||
|
title: "Search \(id)",
|
||||||
|
query: "query-\(id)",
|
||||||
|
createdAt: date,
|
||||||
|
updatedAt: date,
|
||||||
|
requestId: "request-\(id)",
|
||||||
|
latencyMs: 42,
|
||||||
|
error: nil,
|
||||||
|
answerText: answer,
|
||||||
|
answerRequestId: "answer-\(id)",
|
||||||
|
answerCitations: [],
|
||||||
|
answerError: nil,
|
||||||
|
results: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func normalizedAPIBaseURLPreservesExplicitAPIPath() async throws {
|
@Test func normalizedAPIBaseURLPreservesExplicitAPIPath() async throws {
|
||||||
let defaults = UserDefaults(suiteName: #function)!
|
let defaults = UserDefaults(suiteName: #function)!
|
||||||
@@ -22,3 +202,66 @@ import Testing
|
|||||||
|
|
||||||
#expect(settings.normalizedAPIBaseURL?.absoluteString == "http://127.0.0.1:8787")
|
#expect(settings.normalizedAPIBaseURL?.absoluteString == "http://127.0.0.1:8787")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func foregroundListRefreshDoesNotReloadHiddenSelection() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
|
let chat = makeChatSummary(id: "chat-1", date: date)
|
||||||
|
let search = makeSearchSummary(id: "search-1", date: date)
|
||||||
|
let client = MockSybilClient(
|
||||||
|
chatsResponse: [chat],
|
||||||
|
searchesResponse: [search],
|
||||||
|
chatDetails: ["chat-1": makeChatDetail(id: "chat-1", date: date, body: "fresh chat body")]
|
||||||
|
)
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
viewModel.selectedItem = .chat("chat-1")
|
||||||
|
|
||||||
|
await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false)
|
||||||
|
|
||||||
|
let snapshot = await client.currentSnapshot()
|
||||||
|
#expect(snapshot.listChats == 1)
|
||||||
|
#expect(snapshot.listSearches == 1)
|
||||||
|
#expect(snapshot.getChat == 0)
|
||||||
|
#expect(snapshot.getSearch == 0)
|
||||||
|
#expect(viewModel.selectedItem == .chat("chat-1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_100)
|
||||||
|
let detail = makeChatDetail(id: "chat-2", date: date, body: "refreshed transcript")
|
||||||
|
let client = MockSybilClient(chatDetails: ["chat-2": detail])
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
viewModel.selectedItem = .chat("chat-2")
|
||||||
|
|
||||||
|
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
|
||||||
|
|
||||||
|
let snapshot = await client.currentSnapshot()
|
||||||
|
#expect(snapshot.listChats == 0)
|
||||||
|
#expect(snapshot.listSearches == 0)
|
||||||
|
#expect(snapshot.getChat == 1)
|
||||||
|
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func foregroundSearchRefreshReloadsSelectedSearch() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_200)
|
||||||
|
let detail = makeSearchDetail(id: "search-2", date: date, answer: "fresh answer")
|
||||||
|
let client = MockSybilClient(searchDetails: ["search-2": detail])
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
viewModel.selectedItem = .search("search-2")
|
||||||
|
|
||||||
|
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
|
||||||
|
|
||||||
|
let snapshot = await client.currentSnapshot()
|
||||||
|
#expect(snapshot.listChats == 0)
|
||||||
|
#expect(snapshot.listSearches == 0)
|
||||||
|
#expect(snapshot.getSearch == 1)
|
||||||
|
#expect(viewModel.selectedSearch?.answerText == "fresh answer")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user