diff --git a/ios/Apps/Sybil/Info.plist b/ios/Apps/Sybil/Info.plist
new file mode 100644
index 0000000..154b30b
--- /dev/null
+++ b/ios/Apps/Sybil/Info.plist
@@ -0,0 +1,17 @@
+
+
+
+
+ UIApplicationShortcutItems
+
+
+ UIApplicationShortcutItemType
+ net.buzzert.sybil2.quick-question
+ UIApplicationShortcutItemTitle
+ Quick question
+ UIApplicationShortcutItemIconSymbolName
+ sparkles
+
+
+
+
diff --git a/ios/Apps/Sybil/Sources/SybilApp.swift b/ios/Apps/Sybil/Sources/SybilApp.swift
index c812842..df206cf 100644
--- a/ios/Apps/Sybil/Sources/SybilApp.swift
+++ b/ios/Apps/Sybil/Sources/SybilApp.swift
@@ -5,6 +5,8 @@ import UIKit
@main
struct SybilApp: App
{
+ @UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
+
var body: some Scene {
WindowGroup {
SplitView()
@@ -14,3 +16,79 @@ struct SybilApp: App
}
}
}
+
+@MainActor
+final class SybilAppDelegate: NSObject, UIApplicationDelegate {
+ func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
+ ) -> Bool {
+ SybilHomeScreenQuickActionHandler.configureQuickActions()
+ return true
+ }
+
+ func application(
+ _ application: UIApplication,
+ configurationForConnecting connectingSceneSession: UISceneSession,
+ options: UIScene.ConnectionOptions
+ ) -> UISceneConfiguration {
+ let configuration = UISceneConfiguration(
+ name: "Default Configuration",
+ sessionRole: connectingSceneSession.role
+ )
+ configuration.delegateClass = SybilSceneDelegate.self
+ return configuration
+ }
+
+ func application(
+ _ application: UIApplication,
+ performActionFor shortcutItem: UIApplicationShortcutItem,
+ completionHandler: @escaping (Bool) -> Void
+ ) {
+ completionHandler(SybilHomeScreenQuickActionHandler.handle(shortcutItem))
+ }
+}
+
+@MainActor
+final class SybilSceneDelegate: NSObject, UIWindowSceneDelegate {
+ func scene(
+ _ scene: UIScene,
+ willConnectTo session: UISceneSession,
+ options connectionOptions: UIScene.ConnectionOptions
+ ) {
+ if let shortcutItem = connectionOptions.shortcutItem {
+ _ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
+ }
+ }
+
+ func windowScene(
+ _ windowScene: UIWindowScene,
+ performActionFor shortcutItem: UIApplicationShortcutItem,
+ completionHandler: @escaping (Bool) -> Void
+ ) {
+ completionHandler(SybilHomeScreenQuickActionHandler.handle(shortcutItem))
+ }
+
+ func sceneWillResignActive(_ scene: UIScene) {
+ SybilHomeScreenQuickActionHandler.configureQuickActions()
+ }
+}
+
+@MainActor
+private enum SybilHomeScreenQuickActionHandler {
+ static func configureQuickActions() {
+ // The quick question action is static in Info.plist so it is available before first launch.
+ UIApplication.shared.shortcutItems = []
+ }
+
+ static func handle(_ shortcutItem: UIApplicationShortcutItem) -> Bool {
+ guard shortcutItem.type == SybilHomeScreenQuickAction.quickQuestionType else {
+ return false
+ }
+
+ Task { @MainActor in
+ SybilQuickActionRouter.shared.requestQuickQuestionPresentation()
+ }
+ return true
+ }
+}
diff --git a/ios/Apps/Sybil/project.yml b/ios/Apps/Sybil/project.yml
index aff0e87..ef89fde 100644
--- a/ios/Apps/Sybil/project.yml
+++ b/ios/Apps/Sybil/project.yml
@@ -22,6 +22,7 @@ targets:
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
TARGETED_DEVICE_FAMILY: "1,2,6"
GENERATE_INFOPLIST_FILE: YES
+ INFOPLIST_FILE: Apps/Sybil/Info.plist
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
MARKETING_VERSION: 1.7
CURRENT_PROJECT_VERSION: 8
diff --git a/ios/Packages/Sybil/Sources/Sybil/SplitView.swift b/ios/Packages/Sybil/Sources/Sybil/SplitView.swift
index ada0966..5b235a8 100644
--- a/ios/Packages/Sybil/Sources/Sybil/SplitView.swift
+++ b/ios/Packages/Sybil/Sources/Sybil/SplitView.swift
@@ -2,10 +2,14 @@ import SwiftUI
public struct SplitView: View {
@State private var viewModel = SybilViewModel()
+ @ObservedObject private var quickActionRouter = SybilQuickActionRouter.shared
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.scenePhase) private var scenePhase
@State private var shouldRefreshOnForeground = false
@State private var composerFocusRequest = 0
+ @State private var quickQuestionFocusRequest = 0
+ @State private var hasPendingQuickQuestionPresentation = false
+ @State private var isQuickQuestionPresented = false
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
private var keyboardActions: SybilKeyboardActions? {
@@ -74,8 +78,28 @@ public struct SplitView: View {
.font(.sybil(.body))
.preferredColorScheme(.dark)
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
+ .sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
+ SybilQuickQuestionView(
+ viewModel: viewModel,
+ focusRequest: quickQuestionFocusRequest
+ )
+ .presentationDragIndicator(.visible)
+ }
.task {
await viewModel.bootstrap()
+ presentPendingQuickQuestionIfPossible()
+ }
+ .onReceive(quickActionRouter.$quickQuestionPresentationRequest) { request in
+ guard request > 0 else {
+ return
+ }
+ queueQuickQuestionPresentation()
+ }
+ .onChange(of: viewModel.isCheckingSession) { _, _ in
+ presentPendingQuickQuestionIfPossible()
+ }
+ .onChange(of: viewModel.isAuthenticated) { _, _ in
+ presentPendingQuickQuestionIfPossible()
}
.onChange(of: scenePhase) { _, nextPhase in
switch nextPhase {
@@ -112,6 +136,28 @@ public struct SplitView: View {
columnVisibility = .all
}
}
+
+ private func queueQuickQuestionPresentation() {
+ hasPendingQuickQuestionPresentation = true
+ presentPendingQuickQuestionIfPossible()
+ }
+
+ private func presentPendingQuickQuestionIfPossible() {
+ guard hasPendingQuickQuestionPresentation,
+ !viewModel.isCheckingSession,
+ viewModel.isAuthenticated
+ else {
+ return
+ }
+
+ hasPendingQuickQuestionPresentation = false
+ quickQuestionFocusRequest += 1
+ isQuickQuestionPresented = true
+ }
+
+ private func handleQuickQuestionDismissed() {
+ viewModel.cancelQuickQuestion()
+ }
}
public struct SybilCommands: Commands {
diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift
index c272e62..5f9ba48 100644
--- a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift
+++ b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift
@@ -49,11 +49,16 @@ actor SybilAPIClient: SybilAPIClienting {
return response.chats
}
- func createChat(title: String? = nil) async throws -> ChatSummary {
+ func createChat(
+ title: String? = nil,
+ provider: Provider? = nil,
+ model: String? = nil,
+ messages: [CompletionRequestMessage]? = nil
+ ) async throws -> ChatSummary {
let response = try await request(
"/v1/chats",
method: "POST",
- body: AnyEncodable(ChatCreateBody(title: title)),
+ body: AnyEncodable(ChatCreateBody(title: title, provider: provider, model: model, messages: messages)),
responseType: ChatCreateResponse.self
)
return response.chat
@@ -617,6 +622,7 @@ actor SybilAPIClient: SybilAPIClienting {
struct CompletionStreamRequest: Codable, Sendable {
var chatId: String?
+ var persist: Bool? = nil
var provider: Provider
var model: String
var messages: [CompletionRequestMessage]
@@ -624,6 +630,9 @@ struct CompletionStreamRequest: Codable, Sendable {
private struct ChatCreateBody: Encodable {
var title: String?
+ var provider: Provider?
+ var model: String?
+ var messages: [CompletionRequestMessage]?
}
private struct SearchCreateBody: Encodable {
diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift
index a6ac5d3..c7e961e 100644
--- a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift
+++ b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift
@@ -3,7 +3,12 @@ import Foundation
protocol SybilAPIClienting: Sendable {
func verifySession() async throws -> AuthSession
func listChats() async throws -> [ChatSummary]
- func createChat(title: String?) async throws -> ChatSummary
+ func createChat(
+ title: String?,
+ provider: Provider?,
+ model: String?,
+ messages: [CompletionRequestMessage]?
+ ) 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
@@ -32,3 +37,9 @@ protocol SybilAPIClienting: Sendable {
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
) async throws
}
+
+extension SybilAPIClienting {
+ func createChat(title: String?) async throws -> ChatSummary {
+ try await createChat(title: title, provider: nil, model: nil, messages: nil)
+ }
+}
diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift
index 998f718..bb0dce4 100644
--- a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift
+++ b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift
@@ -406,8 +406,8 @@ public struct CompletionRequestMessage: Codable, Sendable {
}
public struct CompletionStreamMeta: Codable, Sendable {
- public var chatId: String
- public var callId: String
+ public var chatId: String?
+ public var callId: String?
public var provider: Provider
public var model: String
}
diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilQuickActionRouting.swift b/ios/Packages/Sybil/Sources/Sybil/SybilQuickActionRouting.swift
new file mode 100644
index 0000000..858d3f5
--- /dev/null
+++ b/ios/Packages/Sybil/Sources/Sybil/SybilQuickActionRouting.swift
@@ -0,0 +1,19 @@
+import Combine
+import Foundation
+
+public enum SybilHomeScreenQuickAction {
+ public static let quickQuestionType = "net.buzzert.sybil2.quick-question"
+}
+
+@MainActor
+public final class SybilQuickActionRouter: ObservableObject {
+ public static let shared = SybilQuickActionRouter()
+
+ @Published public private(set) var quickQuestionPresentationRequest = 0
+
+ private init() {}
+
+ public func requestQuickQuestionPresentation() {
+ quickQuestionPresentationRequest += 1
+ }
+}
diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift
new file mode 100644
index 0000000..342e337
--- /dev/null
+++ b/ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift
@@ -0,0 +1,297 @@
+import MarkdownUI
+import Observation
+import SwiftUI
+
+struct SybilQuickQuestionView: View {
+ @Bindable var viewModel: SybilViewModel
+ var focusRequest: Int
+
+ @Environment(\.dismiss) private var dismiss
+ @FocusState private var promptFocused: Bool
+
+ private var hasAnswerContent: Bool {
+ !viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ VStack(alignment: .leading, spacing: 16) {
+ header
+
+ answerArea
+
+ composer
+ }
+ .padding(.horizontal, 16)
+ .padding(.top, 18)
+ .padding(.bottom, 12)
+ .frame(maxWidth: 640, maxHeight: .infinity, alignment: .top)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+ .background(SybilTheme.backgroundGradient)
+ .preferredColorScheme(.dark)
+ .task(id: focusRequest) {
+ try? await Task.sleep(for: .milliseconds(260))
+ guard !Task.isCancelled else {
+ return
+ }
+ promptFocused = true
+ }
+ }
+
+ private var header: some View {
+ HStack {
+ Image(systemName: "sparkles")
+ .font(.system(size: 21, weight: .semibold))
+ .foregroundStyle(SybilTheme.primary)
+
+ Text("Quick question")
+ .font(.title3.weight(.semibold))
+ .foregroundStyle(SybilTheme.text)
+ .lineLimit(1)
+
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ private var answerArea: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 12) {
+ if hasAnswerContent {
+ ForEach(viewModel.quickQuestionMessages) { message in
+ QuickQuestionMessageView(message: message, isSending: viewModel.isQuickQuestionSending)
+ }
+
+ if let error = viewModel.quickQuestionError {
+ Text(error)
+ .font(.caption)
+ .foregroundStyle(SybilTheme.danger)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ .padding(14)
+ }
+ .scrollDismissesKeyboard(.interactively)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .fill(Color.black.opacity(0.36))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 12)
+ .stroke(SybilTheme.border.opacity(0.55), lineWidth: 1)
+ )
+ }
+
+ private var composer: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(alignment: .bottom, spacing: 10) {
+ TextField(
+ "Ask anything...",
+ text: Binding(
+ get: { viewModel.quickQuestionPrompt },
+ set: { viewModel.updateQuickQuestionPrompt($0) }
+ ),
+ axis: .vertical
+ )
+ .focused($promptFocused)
+ .font(.body)
+ .textInputAutocapitalization(.sentences)
+ .autocorrectionDisabled(false)
+ .lineLimit(1 ... 6)
+ .submitLabel(.send)
+ .onSubmit(submitQuestion)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 10)
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .fill(SybilTheme.composerGradient)
+ .opacity(0.98)
+ )
+ .foregroundStyle(SybilTheme.text)
+
+ Button(action: submitQuestion) {
+ Image(systemName: "arrow.up")
+ .font(.body.weight(.semibold))
+ .frame(width: 40, height: 40)
+ .background(
+ Circle()
+ .fill(
+ viewModel.canSendQuickQuestion
+ ? AnyShapeStyle(SybilTheme.primaryGradient)
+ : AnyShapeStyle(SybilTheme.surfaceStrong.opacity(0.92))
+ )
+ )
+ .foregroundStyle(viewModel.canSendQuickQuestion ? SybilTheme.text : SybilTheme.textMuted)
+ }
+ .buttonStyle(.plain)
+ .disabled(!viewModel.canSendQuickQuestion)
+ .accessibilityLabel("Ask quick question")
+ }
+
+ controlsRow
+ }
+ }
+
+ private var convertButton: some View {
+ Button {
+ Task {
+ let didConvert = await viewModel.convertQuickQuestionToChat()
+ if didConvert {
+ dismiss()
+ }
+ }
+ } label: {
+ Label("Chat", systemImage: "bubble.left")
+ .font(.caption.weight(.medium))
+ .lineLimit(1)
+ .minimumScaleFactor(0.8)
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(viewModel.canConvertQuickQuestion ? SybilTheme.text : SybilTheme.textMuted)
+ .padding(.horizontal, 10)
+ .frame(maxWidth: .infinity, minHeight: 40)
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .fill(SybilTheme.surfaceStrong.opacity(0.78))
+ .overlay(
+ RoundedRectangle(cornerRadius: 12)
+ .stroke(SybilTheme.border.opacity(0.78), lineWidth: 1)
+ )
+ )
+ .disabled(!viewModel.canConvertQuickQuestion)
+ }
+
+ private var controlsRow: some View {
+ HStack(alignment: .center, spacing: 10) {
+ providerMenu
+ modelMenu
+ convertButton
+ }
+ }
+
+ private var providerMenu: some View {
+ Menu {
+ ForEach(viewModel.providerOptions, id: \.self) { provider in
+ Button {
+ viewModel.setQuickQuestionProvider(provider)
+ } label: {
+ if viewModel.quickQuestionProvider == provider {
+ Label(provider.displayName, systemImage: "checkmark")
+ } else {
+ Text(provider.displayName)
+ }
+ }
+ }
+ } label: {
+ QuickQuestionPickerPill(title: viewModel.quickQuestionProvider.displayName)
+ }
+ .frame(maxWidth: .infinity)
+ .disabled(viewModel.isQuickQuestionSending || viewModel.isConvertingQuickQuestion)
+ .accessibilityLabel("Quick question provider")
+ }
+
+ private var modelMenu: some View {
+ Menu {
+ if viewModel.quickQuestionProviderModelOptions.isEmpty {
+ Text("No models")
+ } else {
+ ForEach(viewModel.quickQuestionProviderModelOptions, id: \.self) { model in
+ Button {
+ viewModel.setQuickQuestionModel(model)
+ } label: {
+ if viewModel.quickQuestionModel == model {
+ Label(model, systemImage: "checkmark")
+ } else {
+ Text(model)
+ }
+ }
+ }
+ }
+ } label: {
+ QuickQuestionPickerPill(title: viewModel.quickQuestionModel.isEmpty ? "No model" : viewModel.quickQuestionModel)
+ }
+ .frame(maxWidth: .infinity)
+ .disabled(viewModel.isQuickQuestionSending || viewModel.isConvertingQuickQuestion)
+ .accessibilityLabel("Quick question model")
+ }
+
+ private func submitQuestion() {
+ _ = viewModel.sendQuickQuestion()
+ }
+}
+
+private struct QuickQuestionPickerPill: View {
+ var title: String
+
+ var body: some View {
+ HStack(spacing: 8) {
+ Text(title)
+ .font(.caption.weight(.medium))
+ .foregroundStyle(SybilTheme.text)
+ .lineLimit(1)
+ .minimumScaleFactor(0.8)
+
+ Image(systemName: "chevron.down")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(SybilTheme.textMuted)
+ }
+ .padding(.horizontal, 10)
+ .frame(maxWidth: .infinity, minHeight: 40)
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .fill(SybilTheme.surfaceStrong.opacity(0.78))
+ .overlay(
+ RoundedRectangle(cornerRadius: 12)
+ .stroke(SybilTheme.border.opacity(0.78), lineWidth: 1)
+ )
+ )
+ }
+}
+
+private struct QuickQuestionMessageView: View {
+ var message: Message
+ var isSending: Bool
+
+ private var isPendingAssistant: Bool {
+ message.id.hasPrefix("temp-assistant-quick-") &&
+ isSending &&
+ message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+
+ var body: some View {
+ if let metadata = message.toolCallMetadata {
+ Text(toolCallSummary(for: metadata, fallbackContent: message.content))
+ .font(.caption)
+ .foregroundStyle(SybilTheme.textMuted)
+ .fixedSize(horizontal: false, vertical: true)
+ } else if isPendingAssistant {
+ HStack(spacing: 8) {
+ ProgressView()
+ .controlSize(.small)
+ .tint(SybilTheme.primary)
+ Text("Thinking...")
+ .font(.caption)
+ .foregroundStyle(SybilTheme.textMuted)
+ }
+ } else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ Markdown(message.content)
+ .font(.body)
+ .tint(SybilTheme.primary)
+ .foregroundStyle(SybilTheme.text.opacity(0.96))
+ .textSelection(.enabled)
+ }
+ }
+
+ private func toolCallSummary(for metadata: ToolCallMetadata, fallbackContent: String) -> String {
+ if let summary = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !summary.isEmpty {
+ return summary
+ }
+ if !fallbackContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ return fallbackContent
+ }
+ return "Ran \(metadata.toolName ?? "tool")."
+ }
+}
diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilSettingsStore.swift b/ios/Packages/Sybil/Sources/Sybil/SybilSettingsStore.swift
index 80c5e82..41c9945 100644
--- a/ios/Packages/Sybil/Sources/Sybil/SybilSettingsStore.swift
+++ b/ios/Packages/Sybil/Sources/Sybil/SybilSettingsStore.swift
@@ -12,6 +12,11 @@ final class SybilSettingsStore {
static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel"
static let preferredXAIModel = "sybil.ios.preferredXAIModel"
static let preferredHermesAgentModel = "sybil.ios.preferredHermesAgentModel"
+ static let quickQuestionPreferredProvider = "sybil.ios.quickQuestionPreferredProvider"
+ static let quickQuestionPreferredOpenAIModel = "sybil.ios.quickQuestionPreferredOpenAIModel"
+ static let quickQuestionPreferredAnthropicModel = "sybil.ios.quickQuestionPreferredAnthropicModel"
+ static let quickQuestionPreferredXAIModel = "sybil.ios.quickQuestionPreferredXAIModel"
+ static let quickQuestionPreferredHermesAgentModel = "sybil.ios.quickQuestionPreferredHermesAgentModel"
}
private let defaults: UserDefaults
@@ -20,6 +25,8 @@ final class SybilSettingsStore {
var adminToken: String
var preferredProvider: Provider
var preferredModelByProvider: [Provider: String]
+ var quickQuestionPreferredProvider: Provider
+ var quickQuestionPreferredModelByProvider: [Provider: String]
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
@@ -33,12 +40,22 @@ final class SybilSettingsStore {
let provider = defaults.string(forKey: Keys.preferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
self.preferredProvider = provider
- self.preferredModelByProvider = [
+ let preferredModels: [Provider: String] = [
.openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini",
.anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest",
.xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini",
.hermesAgent: defaults.string(forKey: Keys.preferredHermesAgentModel) ?? "hermes-agent"
]
+ self.preferredModelByProvider = preferredModels
+
+ self.quickQuestionPreferredProvider =
+ defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? provider
+ self.quickQuestionPreferredModelByProvider = [
+ .openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? preferredModels[.openai] ?? "gpt-4.1-mini",
+ .anthropic: defaults.string(forKey: Keys.quickQuestionPreferredAnthropicModel) ?? preferredModels[.anthropic] ?? "claude-3-5-sonnet-latest",
+ .xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini",
+ .hermesAgent: defaults.string(forKey: Keys.quickQuestionPreferredHermesAgentModel) ?? preferredModels[.hermesAgent] ?? "hermes-agent"
+ ]
}
func persist() {
@@ -56,6 +73,12 @@ final class SybilSettingsStore {
defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel)
defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel)
defaults.set(preferredModelByProvider[.hermesAgent], forKey: Keys.preferredHermesAgentModel)
+
+ defaults.set(quickQuestionPreferredProvider.rawValue, forKey: Keys.quickQuestionPreferredProvider)
+ defaults.set(quickQuestionPreferredModelByProvider[.openai], forKey: Keys.quickQuestionPreferredOpenAIModel)
+ defaults.set(quickQuestionPreferredModelByProvider[.anthropic], forKey: Keys.quickQuestionPreferredAnthropicModel)
+ defaults.set(quickQuestionPreferredModelByProvider[.xai], forKey: Keys.quickQuestionPreferredXAIModel)
+ defaults.set(quickQuestionPreferredModelByProvider[.hermesAgent], forKey: Keys.quickQuestionPreferredHermesAgentModel)
}
var trimmedTokenOrNil: String? {
@@ -71,7 +94,7 @@ final class SybilSettingsStore {
raw.removeLast()
}
- guard var components = URLComponents(string: raw) else {
+ guard let components = URLComponents(string: raw) else {
return nil
}
diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift
index 682567c..862359c 100644
--- a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift
+++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift
@@ -111,6 +111,16 @@ final class SybilViewModel {
var provider: Provider
var modelCatalog: [Provider: ProviderModelInfo] = [:]
var model: String
+ var quickQuestionPrompt = ""
+ var quickQuestionMessages: [Message] = []
+ var quickQuestionError: String?
+ var quickQuestionProvider: Provider
+ var quickQuestionModel: String
+ var quickQuestionSubmittedPrompt: String?
+ var quickQuestionSubmittedProvider: Provider?
+ var quickQuestionSubmittedModel: String?
+ var isQuickQuestionSending = false
+ var isConvertingQuickQuestion = false
@ObservationIgnored
private var hasBootstrapped = false
@@ -132,6 +142,10 @@ final class SybilViewModel {
@ObservationIgnored
private var activeSearchAttachTasks: [String: Task] = [:]
@ObservationIgnored
+ private var quickQuestionTask: Task?
+ @ObservationIgnored
+ private var quickQuestionRunID: UUID?
+ @ObservationIgnored
private var isAppActive = true
@ObservationIgnored
private var appLifecycleGeneration = 0
@@ -153,8 +167,14 @@ final class SybilViewModel {
) {
self.settings = settings
self.clientFactory = clientFactory
- self.provider = settings.preferredProvider
- self.model = settings.preferredModelByProvider[settings.preferredProvider] ?? "gpt-4.1-mini"
+ let initialProvider = settings.preferredProvider
+ let initialModel = settings.preferredModelByProvider[initialProvider] ?? "gpt-4.1-mini"
+ self.provider = initialProvider
+ self.model = initialModel
+ let initialQuickQuestionProvider = settings.quickQuestionPreferredProvider
+ let initialQuickQuestionModel = settings.quickQuestionPreferredModelByProvider[initialQuickQuestionProvider] ?? initialModel
+ self.quickQuestionProvider = initialQuickQuestionProvider
+ self.quickQuestionModel = initialQuickQuestionModel
}
var providerModelOptions: [String] {
@@ -167,6 +187,36 @@ final class SybilViewModel {
}
}
+ var quickQuestionProviderModelOptions: [String] {
+ modelOptions(for: quickQuestionProvider)
+ }
+
+ var canSendQuickQuestion: Bool {
+ !isQuickQuestionSending &&
+ !isConvertingQuickQuestion &&
+ !quickQuestionPrompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
+ !quickQuestionModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+
+ var quickQuestionAnswerText: String {
+ for message in quickQuestionMessages.reversed() where message.role == .assistant {
+ let content = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !content.isEmpty {
+ return content
+ }
+ }
+ return ""
+ }
+
+ var canConvertQuickQuestion: Bool {
+ !isQuickQuestionSending &&
+ !isConvertingQuickQuestion &&
+ !(quickQuestionSubmittedPrompt?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) &&
+ !quickQuestionAnswerText.isEmpty &&
+ quickQuestionSubmittedProvider != nil &&
+ !(quickQuestionSubmittedModel?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
+ }
+
func modelOptions(for candidate: Provider) -> [String] {
let serverModels = modelCatalog[candidate]?.models ?? []
if !serverModels.isEmpty {
@@ -422,6 +472,7 @@ final class SybilViewModel {
localActiveSearchIDs = []
serverActiveChatIDs = []
serverActiveSearchIDs = []
+ resetQuickQuestion()
draftIdentity = UUID()
composerAttachments = []
settings.persist()
@@ -494,6 +545,159 @@ final class SybilViewModel {
SybilLog.info(SybilLog.ui, "Provider changed to \(nextProvider.rawValue), model=\(nextModel)")
}
+ func setQuickQuestionProvider(_ nextProvider: Provider) {
+ quickQuestionProvider = nextProvider
+
+ let options = modelOptions(for: nextProvider)
+ if let preferred = settings.quickQuestionPreferredModelByProvider[nextProvider], options.contains(preferred) {
+ quickQuestionModel = preferred
+ } else if let first = options.first {
+ quickQuestionModel = first
+ } else {
+ quickQuestionModel = ""
+ }
+
+ persistQuickQuestionModelSelection()
+ }
+
+ func setQuickQuestionModel(_ nextModel: String) {
+ quickQuestionModel = nextModel
+ persistQuickQuestionModelSelection()
+ }
+
+ private func persistQuickQuestionModelSelection() {
+ settings.quickQuestionPreferredProvider = quickQuestionProvider
+ let trimmedModel = quickQuestionModel.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedModel.isEmpty {
+ settings.quickQuestionPreferredModelByProvider[quickQuestionProvider] = trimmedModel
+ }
+ settings.persist()
+ }
+
+ func updateQuickQuestionPrompt(_ nextPrompt: String) {
+ guard nextPrompt != quickQuestionPrompt else {
+ return
+ }
+
+ if isQuickQuestionSending || quickQuestionSubmittedPrompt != nil || !quickQuestionMessages.isEmpty {
+ cancelQuickQuestion()
+ quickQuestionSubmittedPrompt = nil
+ quickQuestionSubmittedProvider = nil
+ quickQuestionSubmittedModel = nil
+ quickQuestionMessages = []
+ quickQuestionError = nil
+ }
+
+ quickQuestionPrompt = nextPrompt
+ }
+
+ func resetQuickQuestion() {
+ cancelQuickQuestion()
+ quickQuestionPrompt = ""
+ quickQuestionMessages = []
+ quickQuestionError = nil
+ quickQuestionSubmittedPrompt = nil
+ quickQuestionSubmittedProvider = nil
+ quickQuestionSubmittedModel = nil
+ isConvertingQuickQuestion = false
+ }
+
+ func cancelQuickQuestion() {
+ quickQuestionTask?.cancel()
+ quickQuestionTask = nil
+ quickQuestionRunID = nil
+ isQuickQuestionSending = false
+ }
+
+ @discardableResult
+ func sendQuickQuestion() -> Task? {
+ let content = quickQuestionPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !content.isEmpty, !isQuickQuestionSending, !isConvertingQuickQuestion else {
+ return nil
+ }
+
+ let selectedModel = quickQuestionModel.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !selectedModel.isEmpty else {
+ quickQuestionError = "No model available for selected provider."
+ return nil
+ }
+
+ cancelQuickQuestion()
+ let selectedProvider = quickQuestionProvider
+ let task = Task { [weak self] in
+ guard let self else {
+ return
+ }
+ await self.runQuickQuestion(prompt: content, provider: selectedProvider, model: selectedModel)
+ }
+ quickQuestionTask = task
+ return task
+ }
+
+ @discardableResult
+ func convertQuickQuestionToChat() async -> Bool {
+ let question = quickQuestionSubmittedPrompt?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ let answer = quickQuestionAnswerText
+ guard !question.isEmpty,
+ !answer.isEmpty,
+ let submittedProvider = quickQuestionSubmittedProvider,
+ let submittedModel = quickQuestionSubmittedModel?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !submittedModel.isEmpty,
+ !isQuickQuestionSending,
+ !isConvertingQuickQuestion
+ else {
+ return false
+ }
+
+ isConvertingQuickQuestion = true
+ quickQuestionError = nil
+ defer {
+ isConvertingQuickQuestion = false
+ }
+
+ do {
+ let titleSeed = question.split(whereSeparator: \.isNewline).first.map(String.init) ?? question
+ let title = String(titleSeed.trimmingCharacters(in: .whitespacesAndNewlines).prefix(48))
+ let chat = try await client().createChat(
+ title: title.isEmpty ? "Quick question" : title,
+ provider: submittedProvider,
+ model: submittedModel,
+ messages: [
+ CompletionRequestMessage(role: .user, content: question),
+ CompletionRequestMessage(role: .assistant, content: answer)
+ ]
+ )
+
+ setProvider(submittedProvider, model: submittedModel)
+ chats.removeAll(where: { $0.id == chat.id })
+ chats.insert(chat, at: 0)
+ draftKind = nil
+ selectedItem = .chat(chat.id)
+ selectedChat = ChatDetail(
+ id: chat.id,
+ title: chat.title,
+ createdAt: chat.createdAt,
+ updatedAt: chat.updatedAt,
+ initiatedProvider: chat.initiatedProvider,
+ initiatedModel: chat.initiatedModel,
+ lastUsedProvider: chat.lastUsedProvider,
+ lastUsedModel: chat.lastUsedModel,
+ messages: []
+ )
+ selectedSearch = nil
+ composer = ""
+ composerAttachments = []
+
+ await refreshCollections(preferredSelection: .chat(chat.id))
+ resetQuickQuestion()
+ return true
+ } catch {
+ quickQuestionError = normalizeAPIError(error)
+ SybilLog.error(SybilLog.ui, "Convert quick question to chat failed", error: error)
+ return false
+ }
+ }
+
func startNewChat() {
SybilLog.debug(SybilLog.ui, "Starting draft chat")
resetSelectionLoading()
@@ -837,6 +1041,91 @@ final class SybilViewModel {
isCreatingSearchChat = false
}
+ private func runQuickQuestion(prompt: String, provider: Provider, model: String) async {
+ let runID = UUID()
+ quickQuestionRunID = runID
+ quickQuestionError = nil
+ quickQuestionSubmittedPrompt = prompt
+ quickQuestionSubmittedProvider = provider
+ quickQuestionSubmittedModel = model
+ quickQuestionMessages = [
+ Message(
+ id: "temp-assistant-quick-\(UUID().uuidString)",
+ createdAt: Date(),
+ role: .assistant,
+ content: "",
+ name: nil
+ )
+ ]
+ isQuickQuestionSending = true
+
+ defer {
+ if quickQuestionRunID == runID {
+ quickQuestionTask = nil
+ quickQuestionRunID = nil
+ isQuickQuestionSending = false
+ }
+ }
+
+ let streamStatus = CompletionStreamStatus()
+
+ do {
+ try await client().runCompletionStream(
+ body: CompletionStreamRequest(
+ chatId: nil,
+ persist: false,
+ provider: provider,
+ model: model,
+ messages: [CompletionRequestMessage(role: .user, content: prompt)]
+ )
+ ) { [weak self] event in
+ guard let self else { return }
+ await self.applyQuickQuestionCompletionEvent(event, streamStatus: streamStatus)
+ }
+
+ if let streamError = await streamStatus.error() {
+ throw APIError.httpError(statusCode: 502, message: streamError)
+ }
+ } catch {
+ guard quickQuestionRunID == runID else {
+ return
+ }
+ if isCancellation(error) {
+ return
+ }
+
+ quickQuestionError = normalizeAPIError(error)
+ SybilLog.error(SybilLog.ui, "Quick question failed", error: error)
+ }
+ }
+
+ private func applyQuickQuestionCompletionEvent(_ event: CompletionStreamEvent, streamStatus: CompletionStreamStatus) async {
+ switch event {
+ case .meta:
+ break
+
+ case let .toolCall(payload):
+ insertQuickQuestionToolCallMessage(payload)
+
+ case let .delta(payload):
+ guard !payload.text.isEmpty else { return }
+ mutateQuickQuestionAssistantMessage { existing in
+ existing + payload.text
+ }
+
+ case let .done(payload):
+ mutateQuickQuestionAssistantMessage { _ in
+ payload.text
+ }
+
+ case let .error(payload):
+ await streamStatus.setError(payload.message)
+
+ case .ignored:
+ break
+ }
+ }
+
private func loadInitialData(using client: any SybilAPIClienting) async {
isLoadingCollections = true
errorMessage = nil
@@ -914,6 +1203,22 @@ final class SybilViewModel {
model = preferred
}
+ if !providerOptions.contains(quickQuestionProvider), let firstProvider = providerOptions.first {
+ quickQuestionProvider = firstProvider
+ settings.quickQuestionPreferredProvider = firstProvider
+ }
+
+ if !quickQuestionProviderModelOptions.contains(quickQuestionModel), let first = quickQuestionProviderModelOptions.first {
+ quickQuestionModel = first
+ settings.quickQuestionPreferredModelByProvider[quickQuestionProvider] = first
+ }
+
+ if let preferred = settings.quickQuestionPreferredModelByProvider[quickQuestionProvider],
+ quickQuestionProviderModelOptions.contains(preferred)
+ {
+ quickQuestionModel = preferred
+ }
+
settings.persist()
}
@@ -1764,6 +2069,15 @@ final class SybilViewModel {
pendingChatStates[chatID] = pending
}
+ private func mutateQuickQuestionAssistantMessage(_ transform: (String) -> String) {
+ let index = quickQuestionMessages.indices.last { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") }
+ guard let index else {
+ return
+ }
+
+ quickQuestionMessages[index].content = transform(quickQuestionMessages[index].content)
+ }
+
private func insertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
guard var pending = pendingChatStates[chatID] else {
return
@@ -1773,6 +2087,31 @@ final class SybilViewModel {
return
}
+ let message = toolCallMessage(for: payload)
+
+ if let assistantIndex = pending.messages.indices.last(where: { pending.messages[$0].id.hasPrefix("temp-assistant-") }) {
+ pending.messages.insert(message, at: assistantIndex)
+ } else {
+ pending.messages.append(message)
+ }
+
+ pendingChatStates[chatID] = pending
+ }
+
+ private func insertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) {
+ if quickQuestionMessages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) {
+ return
+ }
+
+ let message = toolCallMessage(for: payload)
+ if let assistantIndex = quickQuestionMessages.indices.last(where: { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") }) {
+ quickQuestionMessages.insert(message, at: assistantIndex)
+ } else {
+ quickQuestionMessages.append(message)
+ }
+ }
+
+ private func toolCallMessage(for payload: CompletionStreamToolCall) -> Message {
let metadata: JSONValue = .object([
"kind": .string("tool_call"),
"toolCallId": .string(payload.toolCallId),
@@ -1791,7 +2130,7 @@ final class SybilViewModel {
? "Ran tool '\(payload.name)'."
: payload.summary
- let message = Message(
+ return Message(
id: "temp-tool-\(payload.toolCallId)",
createdAt: Date(),
role: .tool,
@@ -1799,14 +2138,6 @@ final class SybilViewModel {
name: payload.name,
metadata: metadata
)
-
- if let assistantIndex = pending.messages.indices.last(where: { pending.messages[$0].id.hasPrefix("temp-assistant-") }) {
- pending.messages.insert(message, at: assistantIndex)
- } else {
- pending.messages.append(message)
- }
-
- pendingChatStates[chatID] = pending
}
private var currentChatID: String? {
diff --git a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift
index b73751e..3835851 100644
--- a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift
+++ b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift
@@ -6,13 +6,22 @@ import Testing
private struct MockClientCallSnapshot: Sendable {
var listChats = 0
var listSearches = 0
+ var createChat = 0
var getChat = 0
var getSearch = 0
var getActiveRuns = 0
+ var runCompletionStream = 0
var attachCompletionStream = 0
var attachSearchStream = 0
}
+private struct ChatCreateCallSnapshot: Sendable {
+ var title: String?
+ var provider: Provider?
+ var model: String?
+ var messages: [CompletionRequestMessage]?
+}
+
private struct UnexpectedClientCall: Error {}
private actor MockSybilClient: SybilAPIClienting {
@@ -24,6 +33,9 @@ private actor MockSybilClient: SybilAPIClienting {
private let activeRunsResponse: ActiveRunsResponse
private var snapshot = MockClientCallSnapshot()
+ private var lastCreateChatCall: ChatCreateCallSnapshot?
+ private var lastCompletionStreamBody: CompletionStreamRequest?
+ private var completionStreamEvents: [CompletionStreamEvent]?
private var getChatDelayNanoseconds: UInt64 = 0
private var getSearchDelayNanoseconds: UInt64 = 0
private var completionStreamNetworkErrorMessage: String?
@@ -55,6 +67,19 @@ private actor MockSybilClient: SybilAPIClienting {
snapshot
}
+ func currentCreateChatCall() -> ChatCreateCallSnapshot? {
+ lastCreateChatCall
+ }
+
+ func currentCompletionStreamBody() -> CompletionStreamRequest? {
+ lastCompletionStreamBody
+ }
+
+ func setCompletionStreamEvents(_ events: [CompletionStreamEvent], delayNanoseconds: UInt64 = 0) {
+ completionStreamEvents = events
+ completionStreamDelayNanoseconds = delayNanoseconds
+ }
+
func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
completionStreamNetworkErrorMessage = message
completionStreamDelayNanoseconds = delayNanoseconds
@@ -100,7 +125,19 @@ private actor MockSybilClient: SybilAPIClienting {
return chatsResponse
}
- func createChat(title: String?) async throws -> ChatSummary {
+ func createChat(
+ title: String?,
+ provider: Provider?,
+ model: String?,
+ messages: [CompletionRequestMessage]?
+ ) async throws -> ChatSummary {
+ snapshot.createChat += 1
+ lastCreateChatCall = ChatCreateCallSnapshot(
+ title: title,
+ provider: provider,
+ model: model,
+ messages: messages
+ )
if let createChatResponse {
return createChatResponse
}
@@ -167,12 +204,20 @@ private actor MockSybilClient: SybilAPIClienting {
body: CompletionStreamRequest,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
) async throws {
+ snapshot.runCompletionStream += 1
+ lastCompletionStreamBody = body
if completionStreamDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
}
if let completionStreamNetworkErrorMessage {
throw APIError.networkError(message: completionStreamNetworkErrorMessage)
}
+ if let completionStreamEvents {
+ for event in completionStreamEvents {
+ await onEvent(event)
+ }
+ return
+ }
throw UnexpectedClientCall()
}
@@ -470,6 +515,117 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
await sendTask.value
}
+@MainActor
+@Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
+ let client = MockSybilClient()
+ await client.setCompletionStreamEvents([
+ .delta(CompletionStreamDelta(text: "Reset it from ")),
+ .done(CompletionStreamDone(text: "Reset it from Settings."))
+ ])
+ let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
+ viewModel.isAuthenticated = true
+ viewModel.isCheckingSession = false
+ viewModel.quickQuestionPrompt = "How do I reset my password?"
+
+ let task = viewModel.sendQuickQuestion()
+ await task?.value
+
+ let snapshot = await client.currentSnapshot()
+ let body = await client.currentCompletionStreamBody()
+ #expect(snapshot.runCompletionStream == 1)
+ #expect(body?.persist == false)
+ #expect(body?.chatId == nil)
+ #expect(body?.provider == .openai)
+ #expect(body?.messages.first?.role == .user)
+ #expect(body?.messages.first?.content == "How do I reset my password?")
+ #expect(viewModel.quickQuestionAnswerText == "Reset it from Settings.")
+ #expect(!viewModel.isQuickQuestionSending)
+}
+
+@MainActor
+@Test func quickQuestionConvertCreatesSeededChat() async throws {
+ let date = Date(timeIntervalSince1970: 1_700_000_250)
+ let chat = makeChatSummary(id: "quick-chat", date: date)
+ let detail = ChatDetail(
+ id: chat.id,
+ title: chat.title,
+ createdAt: chat.createdAt,
+ updatedAt: chat.updatedAt,
+ initiatedProvider: .openai,
+ initiatedModel: "gpt-4.1-mini",
+ lastUsedProvider: .openai,
+ lastUsedModel: "gpt-4.1-mini",
+ messages: [
+ Message(id: "quick-user", createdAt: date, role: .user, content: "How do I reset my password?", name: nil),
+ Message(id: "quick-assistant", createdAt: date, role: .assistant, content: "Reset it from Settings.", name: nil)
+ ]
+ )
+ let client = MockSybilClient(
+ chatsResponse: [chat],
+ chatDetails: [chat.id: detail],
+ createChatResponse: chat
+ )
+ let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
+ viewModel.isAuthenticated = true
+ viewModel.isCheckingSession = false
+ viewModel.quickQuestionSubmittedPrompt = "How do I reset my password?"
+ viewModel.quickQuestionSubmittedProvider = .openai
+ viewModel.quickQuestionSubmittedModel = "gpt-4.1-mini"
+ viewModel.quickQuestionMessages = [
+ Message(
+ id: "temp-assistant-quick",
+ createdAt: date,
+ role: .assistant,
+ content: "Reset it from Settings.",
+ name: nil
+ )
+ ]
+
+ let didConvert = await viewModel.convertQuickQuestionToChat()
+
+ let snapshot = await client.currentSnapshot()
+ let createCall = await client.currentCreateChatCall()
+ #expect(didConvert)
+ #expect(snapshot.createChat == 1)
+ #expect(createCall?.title == "How do I reset my password?")
+ #expect(createCall?.provider == .openai)
+ #expect(createCall?.model == "gpt-4.1-mini")
+ #expect(createCall?.messages?.map(\.role) == [.user, .assistant])
+ #expect(createCall?.messages?.map(\.content) == ["How do I reset my password?", "Reset it from Settings."])
+ #expect(viewModel.selectedItem == .chat("quick-chat"))
+ #expect(viewModel.quickQuestionPrompt.isEmpty)
+}
+
+@MainActor
+@Test func quickQuestionProviderAndModelSelectionPersistSeparately() async throws {
+ let defaults = UserDefaults(suiteName: #function)!
+ defaults.removePersistentDomain(forName: #function)
+ let settings = SybilSettingsStore(defaults: defaults)
+ settings.apiBaseURL = "http://127.0.0.1:8787"
+ let viewModel = SybilViewModel(settings: settings) { _ in MockSybilClient() }
+ viewModel.modelCatalog = [
+ .openai: ProviderModelInfo(models: ["gpt-4.1-mini", "gpt-4o"], loadedAt: nil, error: nil),
+ .anthropic: ProviderModelInfo(models: ["claude-3-5-sonnet-latest", "claude-3-haiku"], loadedAt: nil, error: nil)
+ ]
+
+ viewModel.setQuickQuestionProvider(.anthropic)
+ viewModel.setQuickQuestionModel("claude-3-haiku")
+
+ #expect(viewModel.quickQuestionProvider == .anthropic)
+ #expect(viewModel.quickQuestionModel == "claude-3-haiku")
+ #expect(settings.preferredProvider == .openai)
+
+ let reloadedSettings = SybilSettingsStore(defaults: defaults)
+ #expect(reloadedSettings.quickQuestionPreferredProvider == .anthropic)
+ #expect(reloadedSettings.quickQuestionPreferredModelByProvider[.anthropic] == "claude-3-haiku")
+ #expect(reloadedSettings.preferredProvider == .openai)
+
+ let reloadedViewModel = SybilViewModel(settings: reloadedSettings) { _ in MockSybilClient() }
+ #expect(reloadedViewModel.quickQuestionProvider == .anthropic)
+ #expect(reloadedViewModel.quickQuestionModel == "claude-3-haiku")
+ #expect(reloadedViewModel.provider == .openai)
+}
+
@MainActor
@Test func reconnectAttachesSelectedActiveChatStream() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_260)