adds quick question UI for mac catalyst target
This commit is contained in:
@@ -78,6 +78,7 @@ public struct SplitView: View {
|
||||
.font(.sybil(.body))
|
||||
.preferredColorScheme(.dark)
|
||||
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
|
||||
SybilQuickQuestionView(
|
||||
viewModel: viewModel,
|
||||
@@ -85,7 +86,11 @@ public struct SplitView: View {
|
||||
)
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
#endif
|
||||
.task {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||
#endif
|
||||
await viewModel.bootstrap()
|
||||
presentPendingQuickQuestionIfPossible()
|
||||
}
|
||||
@@ -107,6 +112,9 @@ public struct SplitView: View {
|
||||
shouldRefreshOnForeground = true
|
||||
viewModel.markAppInactiveForNetwork()
|
||||
case .active:
|
||||
#if targetEnvironment(macCatalyst)
|
||||
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||
#endif
|
||||
viewModel.markAppActiveForNetwork()
|
||||
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
|
||||
return
|
||||
@@ -151,8 +159,13 @@ public struct SplitView: View {
|
||||
}
|
||||
|
||||
hasPendingQuickQuestionPresentation = false
|
||||
#if targetEnvironment(macCatalyst)
|
||||
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||
SybilMacQuickQuestionController.shared.show()
|
||||
#else
|
||||
quickQuestionFocusRequest += 1
|
||||
isQuickQuestionPresented = true
|
||||
#endif
|
||||
}
|
||||
|
||||
private func handleQuickQuestionDismissed() {
|
||||
@@ -161,23 +174,57 @@ public struct SplitView: View {
|
||||
}
|
||||
|
||||
public struct SybilCommands: Commands {
|
||||
public static let workspaceWindowID = "sybil-workspace"
|
||||
|
||||
@FocusedValue(\.sybilKeyboardActions) private var keyboardActions
|
||||
#if targetEnvironment(macCatalyst)
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@ObservedObject private var windowCommands = SybilMacWindowCommands.shared
|
||||
#endif
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some Commands {
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("New Chat") {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
windowCommands.newChatOrWindow(
|
||||
newChat: keyboardActions?.newChat,
|
||||
openWindow: openNewWindow
|
||||
)
|
||||
#else
|
||||
keyboardActions?.newChat()
|
||||
#endif
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
#if targetEnvironment(macCatalyst)
|
||||
.disabled(!windowCommands.canStartNewChat(hasFocusedActions: keyboardActions != nil))
|
||||
#else
|
||||
.disabled(keyboardActions == nil)
|
||||
#endif
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
Button("New Window", action: openNewWindow)
|
||||
.keyboardShortcut("n", modifiers: [.command, .shift])
|
||||
#endif
|
||||
|
||||
Button("New Search") {
|
||||
keyboardActions?.newSearch()
|
||||
}
|
||||
#if targetEnvironment(macCatalyst)
|
||||
.keyboardShortcut("n", modifiers: [.command, .option])
|
||||
#else
|
||||
.keyboardShortcut("n", modifiers: [.command, .shift])
|
||||
#endif
|
||||
.disabled(keyboardActions == nil)
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
Divider()
|
||||
Button("Quick Question") {
|
||||
SybilMacQuickQuestionController.shared.toggle()
|
||||
}
|
||||
.keyboardShortcut(.space, modifiers: .option)
|
||||
#endif
|
||||
}
|
||||
|
||||
CommandMenu("Conversation") {
|
||||
@@ -194,6 +241,13 @@ public struct SybilCommands: Commands {
|
||||
.disabled(keyboardActions == nil)
|
||||
}
|
||||
}
|
||||
#if targetEnvironment(macCatalyst)
|
||||
private func openNewWindow() {
|
||||
// Targeting a WindowGroup without a value creates a fresh scene every
|
||||
// time, even when another workspace window is already open.
|
||||
openWindow(id: Self.workspaceWindowID)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private struct SybilKeyboardActions {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
|
||||
// This Foundation-only contract is compiled into both the Catalyst client and the
|
||||
// native macOS bundle. Only Objective-C-compatible values cross the bundle boundary.
|
||||
@MainActor
|
||||
@objc(SybilQuickQuestionPanelBridging)
|
||||
protocol SybilQuickQuestionPanelBridging: NSObjectProtocol {
|
||||
init()
|
||||
var isVisible: Bool { get }
|
||||
func start(delegate: any SybilQuickQuestionPanelDelegate) -> Int32
|
||||
func updateState(_ data: Data)
|
||||
func show()
|
||||
func hide()
|
||||
func activateForQuickQuestion()
|
||||
func activateMainWindow()
|
||||
func stop()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@objc(SybilQuickQuestionPanelDelegate)
|
||||
protocol SybilQuickQuestionPanelDelegate: NSObjectProtocol {
|
||||
func quickQuestionToggleRequested()
|
||||
func quickQuestionPromptChanged(_ prompt: String)
|
||||
func quickQuestionSubmitRequested()
|
||||
func quickQuestionProviderChanged(_ provider: String)
|
||||
func quickQuestionModelChanged(_ model: String)
|
||||
func quickQuestionConvertRequested()
|
||||
func quickQuestionOpenAppRequested()
|
||||
func quickQuestionPanelDismissed()
|
||||
}
|
||||
|
||||
struct SybilQuickQuestionPanelState: Codable, Equatable, Sendable {
|
||||
struct ProviderOption: Codable, Equatable, Sendable {
|
||||
var id: String
|
||||
var title: String
|
||||
}
|
||||
|
||||
var prompt = ""
|
||||
var answer = ""
|
||||
var toolSummaries: [String] = []
|
||||
var provider = ""
|
||||
var providers: [ProviderOption] = []
|
||||
var model = ""
|
||||
var models: [String] = []
|
||||
var isSending = false
|
||||
var isConverting = false
|
||||
var canSend = false
|
||||
var canConvert = false
|
||||
var isAuthenticated = false
|
||||
var isCheckingSession = true
|
||||
var error: String?
|
||||
|
||||
var isBusy: Bool { isSending || isConverting }
|
||||
var hasResponse: Bool { !answer.isEmpty || !toolSummaries.isEmpty || isSending || error != nil }
|
||||
}
|
||||
|
||||
// Carbon can deliver multiple presses while a key is held. Consume one toggle
|
||||
// per press/release pair, without suppressing a second deliberate press.
|
||||
struct SybilQuickQuestionHotKeyState {
|
||||
private var isPressed = false
|
||||
|
||||
mutating func shouldToggle(isKeyDown: Bool) -> Bool {
|
||||
if !isKeyDown {
|
||||
isPressed = false
|
||||
return false
|
||||
}
|
||||
guard !isPressed else { return false }
|
||||
isPressed = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
#if targetEnvironment(macCatalyst)
|
||||
import Combine
|
||||
import Foundation
|
||||
import Observation
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
public final class SybilMacQuickQuestionController: NSObject, SybilQuickQuestionPanelDelegate {
|
||||
public static let shared = SybilMacQuickQuestionController()
|
||||
|
||||
private var pluginBundle: Bundle?
|
||||
private var bridge: (any SybilQuickQuestionPanelBridging)?
|
||||
private var viewModel: SybilViewModel?
|
||||
private var observationGeneration = 0
|
||||
private var isPresentationRequested = false
|
||||
private var activationRequested = false
|
||||
private var presentationTask: Task<Void, Never>?
|
||||
private var foregroundObservation: AnyCancellable?
|
||||
private var notificationCenter = NotificationCenter.default
|
||||
private var isApplicationInForeground: @MainActor () -> Bool = {
|
||||
UIApplication.shared.applicationState != .background
|
||||
}
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
// Inject foreground readiness as well as the panel so tests can exercise
|
||||
// windowless activation without activating apps or showing real windows.
|
||||
init(
|
||||
bridge: any SybilQuickQuestionPanelBridging,
|
||||
isApplicationInForeground: @escaping @MainActor () -> Bool = { true },
|
||||
notificationCenter: NotificationCenter = NotificationCenter()
|
||||
) {
|
||||
self.bridge = bridge
|
||||
self.isApplicationInForeground = isApplicationInForeground
|
||||
self.notificationCenter = notificationCenter
|
||||
super.init()
|
||||
_ = bridge.start(delegate: self)
|
||||
observeForegroundTransitionsIfNeeded()
|
||||
}
|
||||
|
||||
public func start() {
|
||||
observeForegroundTransitionsIfNeeded()
|
||||
guard bridge == nil else { return }
|
||||
guard let url = Bundle.main.builtInPlugInsURL?.appendingPathComponent("SybilMacQuickQuestion.bundle"),
|
||||
let bundle = Bundle(url: url)
|
||||
else {
|
||||
SybilLog.error(SybilLog.app, "The macOS Quick Question bundle is missing.")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try bundle.loadAndReturnError()
|
||||
guard let pluginClass = bundle.principalClass as? any SybilQuickQuestionPanelBridging.Type else {
|
||||
SybilLog.error(SybilLog.app, "The macOS Quick Question bundle has an invalid principal class.")
|
||||
return
|
||||
}
|
||||
let plugin = pluginClass.init()
|
||||
pluginBundle = bundle
|
||||
bridge = plugin
|
||||
let status = plugin.start(delegate: self)
|
||||
if status != 0 {
|
||||
SybilLog.warning(SybilLog.app, "Option+Space could not be registered (status \(status)). Quick Question remains available in the app menu.")
|
||||
}
|
||||
} catch {
|
||||
SybilLog.error(SybilLog.app, "Could not load the macOS Quick Question panel", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
func attach(_ viewModel: SybilViewModel) {
|
||||
guard self.viewModel !== viewModel else { return }
|
||||
// A window becoming active must not replace an in-flight quick question.
|
||||
if self.viewModel != nil,
|
||||
isPresentationRequested || bridge?.isVisible == true || self.viewModel?.isQuickQuestionSending == true || self.viewModel?.isConvertingQuickQuestion == true {
|
||||
return
|
||||
}
|
||||
self.viewModel = viewModel
|
||||
if bridge?.isVisible == true { updateAndObserve() }
|
||||
}
|
||||
|
||||
public func toggle() {
|
||||
start()
|
||||
if isPresentationRequested || bridge?.isVisible == true {
|
||||
cancelPresentation()
|
||||
bridge?.hide()
|
||||
} else {
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
public func show() {
|
||||
start()
|
||||
isPresentationRequested = true
|
||||
schedulePresentationIfNeeded()
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
cancelPresentation()
|
||||
foregroundObservation = nil
|
||||
viewModel?.cancelQuickQuestion()
|
||||
bridge?.stop()
|
||||
bridge = nil
|
||||
// Keep the loaded bundle alive for the lifetime of its code.
|
||||
}
|
||||
|
||||
private func observeForegroundTransitionsIfNeeded() {
|
||||
guard foregroundObservation == nil else { return }
|
||||
foregroundObservation = notificationCenter.publisher(for: UIApplication.willEnterForegroundNotification)
|
||||
.merge(with: notificationCenter.publisher(for: UIApplication.didBecomeActiveNotification))
|
||||
.sink { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.schedulePresentationIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func schedulePresentationIfNeeded() {
|
||||
guard isPresentationRequested, bridge?.isVisible != true, presentationTask == nil else { return }
|
||||
// Leave the Carbon/NSApplication event callback before touching windows.
|
||||
// AppKit activation is asynchronous; an arbitrary delay or NSApp.isActive
|
||||
// is not a substitute for checking UIKit's foreground lifecycle state.
|
||||
presentationTask = Task { @MainActor [weak self] in
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
self.presentationTask = nil
|
||||
guard self.isPresentationRequested else { return }
|
||||
guard self.isApplicationInForeground() else {
|
||||
guard !self.activationRequested else { return }
|
||||
self.activationRequested = true
|
||||
SybilLog.debug(SybilLog.ui, "Waiting for Catalyst foreground before showing Quick Question")
|
||||
self.bridge?.activateForQuickQuestion()
|
||||
// Also handle an activation that completes synchronously. If it
|
||||
// has not completed, subsequent UIKit notifications resume us.
|
||||
self.schedulePresentationIfNeeded()
|
||||
return
|
||||
}
|
||||
self.activationRequested = false
|
||||
self.updateAndObserve()
|
||||
self.bridge?.show()
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelPresentation() {
|
||||
isPresentationRequested = false
|
||||
activationRequested = false
|
||||
presentationTask?.cancel()
|
||||
presentationTask = nil
|
||||
observationGeneration += 1
|
||||
}
|
||||
|
||||
private func updateAndObserve() {
|
||||
guard let bridge else { return }
|
||||
observationGeneration += 1
|
||||
let generation = observationGeneration
|
||||
let state = withObservationTracking {
|
||||
viewModel?.macQuickQuestionPanelState ?? SybilQuickQuestionPanelState()
|
||||
} onChange: { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, self.observationGeneration == generation, self.bridge?.isVisible == true else { return }
|
||||
self.updateAndObserve()
|
||||
}
|
||||
}
|
||||
do {
|
||||
bridge.updateState(try JSONEncoder().encode(state))
|
||||
} catch {
|
||||
SybilLog.error(SybilLog.ui, "Could not update the macOS Quick Question panel", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
func quickQuestionToggleRequested() {
|
||||
toggle()
|
||||
}
|
||||
|
||||
func quickQuestionPromptChanged(_ prompt: String) {
|
||||
guard let viewModel, !viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
|
||||
viewModel.updateQuickQuestionPrompt(prompt)
|
||||
updateAndObserve()
|
||||
}
|
||||
|
||||
func quickQuestionSubmitRequested() {
|
||||
guard let viewModel, viewModel.isAuthenticated, !viewModel.isCheckingSession else { return }
|
||||
viewModel.sendQuickQuestion()
|
||||
updateAndObserve()
|
||||
}
|
||||
|
||||
func quickQuestionProviderChanged(_ provider: String) {
|
||||
guard let viewModel, let provider = Provider(rawValue: provider),
|
||||
!viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
|
||||
viewModel.setQuickQuestionProvider(provider)
|
||||
updateAndObserve()
|
||||
}
|
||||
|
||||
func quickQuestionModelChanged(_ model: String) {
|
||||
guard let viewModel, !viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
|
||||
viewModel.setQuickQuestionModel(model)
|
||||
updateAndObserve()
|
||||
}
|
||||
|
||||
func quickQuestionConvertRequested() {
|
||||
guard let viewModel, viewModel.canConvertQuickQuestion else { return }
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
if await viewModel.convertQuickQuestionToChat() {
|
||||
self.cancelPresentation()
|
||||
self.openMainWindow()
|
||||
self.bridge?.hide()
|
||||
} else {
|
||||
self.updateAndObserve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func quickQuestionOpenAppRequested() {
|
||||
cancelPresentation()
|
||||
openMainWindow()
|
||||
bridge?.hide()
|
||||
}
|
||||
|
||||
private func openMainWindow() {
|
||||
let session = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.first?.session
|
||||
UIApplication.shared.requestSceneSessionActivation(session, userActivity: nil, options: nil)
|
||||
bridge?.activateMainWindow()
|
||||
}
|
||||
|
||||
func quickQuestionPanelDismissed() {
|
||||
// Toggling out preserves the draft and lets an answer finish. Reopening
|
||||
// reads the latest state, without starting a new request.
|
||||
cancelPresentation()
|
||||
}
|
||||
}
|
||||
|
||||
extension SybilViewModel {
|
||||
var macQuickQuestionPanelState: SybilQuickQuestionPanelState {
|
||||
SybilQuickQuestionPanelState(
|
||||
prompt: quickQuestionPrompt,
|
||||
answer: quickQuestionAnswerText,
|
||||
toolSummaries: quickQuestionMessages.compactMap { message in
|
||||
guard let metadata = message.toolCallMetadata else { return nil }
|
||||
return metadata.summary ?? message.content
|
||||
},
|
||||
provider: quickQuestionProvider.rawValue,
|
||||
providers: providerOptions.map { .init(id: $0.rawValue, title: $0.displayName) },
|
||||
model: quickQuestionModel,
|
||||
models: quickQuestionProviderModelOptions,
|
||||
isSending: isQuickQuestionSending,
|
||||
isConverting: isConvertingQuickQuestion,
|
||||
canSend: isAuthenticated && !isCheckingSession && canSendQuickQuestion,
|
||||
canConvert: canConvertQuickQuestion,
|
||||
isAuthenticated: isAuthenticated,
|
||||
isCheckingSession: isCheckingSession,
|
||||
error: quickQuestionError
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,37 @@
|
||||
#if targetEnvironment(macCatalyst)
|
||||
import Combine
|
||||
|
||||
// Scene lifetime is independent of focused commands: a connected window may
|
||||
// still be authenticating, inactive, or minimized. The native Quick Question
|
||||
// panel is not a workspace scene and does not count as an open window here.
|
||||
@MainActor
|
||||
public final class SybilMacWindowCommands: ObservableObject {
|
||||
public static let shared = SybilMacWindowCommands()
|
||||
|
||||
@Published private var connectedWindowIDs: Set<String> = []
|
||||
|
||||
init() {}
|
||||
|
||||
var hasOpenWindows: Bool { !connectedWindowIDs.isEmpty }
|
||||
|
||||
public func windowConnected(sessionID: String) {
|
||||
connectedWindowIDs.insert(sessionID)
|
||||
}
|
||||
|
||||
public func windowDisconnected(sessionID: String) {
|
||||
connectedWindowIDs.remove(sessionID)
|
||||
}
|
||||
|
||||
func canStartNewChat(hasFocusedActions: Bool) -> Bool {
|
||||
!hasOpenWindows || hasFocusedActions
|
||||
}
|
||||
|
||||
func newChatOrWindow(newChat: (() -> Void)?, openWindow: () -> Void) {
|
||||
if hasOpenWindows {
|
||||
newChat?()
|
||||
} else {
|
||||
openWindow()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -8,6 +8,7 @@ struct SybilQuickQuestionView: View {
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@FocusState private var promptFocused: Bool
|
||||
@State private var promptSelection: TextSelection?
|
||||
|
||||
private var hasAnswerContent: Bool {
|
||||
!viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil
|
||||
@@ -36,6 +37,8 @@ struct SybilQuickQuestionView: View {
|
||||
return
|
||||
}
|
||||
promptFocused = true
|
||||
let prompt = viewModel.quickQuestionPrompt
|
||||
promptSelection = TextSelection(range: prompt.startIndex..<prompt.endIndex)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +97,7 @@ struct SybilQuickQuestionView: View {
|
||||
get: { viewModel.quickQuestionPrompt },
|
||||
set: { viewModel.updateQuickQuestionPrompt($0) }
|
||||
),
|
||||
selection: $promptSelection,
|
||||
axis: .vertical
|
||||
)
|
||||
.focused($promptFocused)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import SwiftUI
|
||||
|
||||
// A vertical TextField treats Return as editing, not form submission. Handle
|
||||
// unmodified Return explicitly on Mac, leaving Shift+Return and other modified
|
||||
// keys to the native text field. iOS keeps its software-keyboard behavior.
|
||||
@MainActor
|
||||
enum SybilReturnKeySubmission {
|
||||
static func handle(
|
||||
phase: KeyPress.Phases,
|
||||
modifiers: EventModifiers,
|
||||
submit: () -> Void
|
||||
) -> KeyPress.Result {
|
||||
guard modifiers.intersection([.shift, .command, .control, .option]).isEmpty else {
|
||||
return .ignored
|
||||
}
|
||||
if phase == .down {
|
||||
submit()
|
||||
}
|
||||
// Consume autorepeat without submitting or inserting a newline.
|
||||
return .handled
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func submitOnMacReturn(_ submit: @escaping () -> Void) -> some View {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
onKeyPress(keys: [.return, KeyEquivalent("\u{3}")], phases: [.down, .repeat]) { press in
|
||||
SybilReturnKeySubmission.handle(phase: press.phase, modifiers: press.modifiers, submit: submit)
|
||||
}
|
||||
#else
|
||||
self
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -636,9 +636,12 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
cancelQuickQuestion()
|
||||
// Lock submission before the task starts, including repeated Return
|
||||
// events delivered in the same main-actor turn.
|
||||
isQuickQuestionSending = true
|
||||
let selectedProvider = quickQuestionProvider
|
||||
let task = Task { [weak self] in
|
||||
guard let self else {
|
||||
guard let self, !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
await self.runQuickQuestion(prompt: content, provider: selectedProvider, model: selectedModel)
|
||||
|
||||
@@ -601,6 +601,7 @@ struct SybilWorkspaceView: View {
|
||||
.onSubmit {
|
||||
submitComposer()
|
||||
}
|
||||
.submitOnMacReturn(submitComposer)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Testing
|
||||
#if targetEnvironment(macCatalyst)
|
||||
import UIKit
|
||||
#endif
|
||||
@testable import Sybil
|
||||
|
||||
private struct MockClientCallSnapshot: Sendable {
|
||||
@@ -951,6 +955,76 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
||||
await sendTask.value
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func composerReturnSubmitsOnceAndConsumesRepeats() {
|
||||
var submissions = 0
|
||||
let initial = SybilReturnKeySubmission.handle(phase: .down, modifiers: []) { submissions += 1 }
|
||||
let repeated = SybilReturnKeySubmission.handle(phase: .repeat, modifiers: []) { submissions += 1 }
|
||||
#expect(initial == .handled)
|
||||
#expect(repeated == .handled)
|
||||
#expect(submissions == 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func composerModifiedReturnPreservesNativeEditing() {
|
||||
var submissions = 0
|
||||
for modifiers: EventModifiers in [.shift, .command, .control, .option, [.shift, .command]] {
|
||||
let result = SybilReturnKeySubmission.handle(phase: .down, modifiers: modifiers) { submissions += 1 }
|
||||
#expect(result == .ignored)
|
||||
}
|
||||
#expect(submissions == 0)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func composerReturnStillSubmitsWithCapsLock() {
|
||||
var submissions = 0
|
||||
let result = SybilReturnKeySubmission.handle(phase: .down, modifiers: .capsLock) { submissions += 1 }
|
||||
#expect(result == .handled)
|
||||
#expect(submissions == 1)
|
||||
}
|
||||
|
||||
@Test func quickQuestionHotKeyTogglesOncePerPress() {
|
||||
var keyState = SybilQuickQuestionHotKeyState()
|
||||
let keyDownEvents = [true, true, true, false, true, false, false, true]
|
||||
let toggles = keyDownEvents.map { keyState.shouldToggle(isKeyDown: $0) }
|
||||
#expect(toggles == [true, false, false, false, true, false, false, true])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func quickQuestionRejectsDuplicateSubmissionBeforeTaskStarts() async throws {
|
||||
let client = MockSybilClient()
|
||||
await client.setCompletionStreamEvents([.done(CompletionStreamDone(text: "One answer."))])
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.quickQuestionPrompt = "One question"
|
||||
|
||||
let first = viewModel.sendQuickQuestion()
|
||||
let duplicate = viewModel.sendQuickQuestion()
|
||||
#expect(first != nil)
|
||||
#expect(duplicate == nil)
|
||||
#expect(viewModel.isQuickQuestionSending)
|
||||
await first?.value
|
||||
|
||||
let calls = await client.currentSnapshot()
|
||||
#expect(calls.runCompletionStream == 1)
|
||||
#expect(viewModel.quickQuestionAnswerText == "One answer.")
|
||||
#expect(!viewModel.isQuickQuestionSending)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func quickQuestionCancelledBeforeTaskStartsDoesNotSend() async throws {
|
||||
let client = MockSybilClient()
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.quickQuestionPrompt = "Do not send this"
|
||||
|
||||
let task = viewModel.sendQuickQuestion()
|
||||
viewModel.cancelQuickQuestion()
|
||||
await task?.value
|
||||
|
||||
let calls = await client.currentSnapshot()
|
||||
#expect(calls.runCompletionStream == 0)
|
||||
#expect(!viewModel.isQuickQuestionSending)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
|
||||
let client = MockSybilClient()
|
||||
@@ -1220,3 +1294,365 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
||||
#expect(BackSwipeMetrics.shouldComplete(offset: 24, velocityX: 800, width: width, isLatched: false))
|
||||
#expect(!BackSwipeMetrics.shouldComplete(offset: latchDistance + 1, velocityX: -800, width: width, isLatched: true))
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
@MainActor
|
||||
@Test func macNewChatCommandOpensWindowWhenNoWorkspacesRemain() {
|
||||
let commands = SybilMacWindowCommands()
|
||||
var chats = 0
|
||||
var windows = 0
|
||||
|
||||
#expect(commands.canStartNewChat(hasFocusedActions: false))
|
||||
commands.newChatOrWindow(newChat: nil, openWindow: { windows += 1 })
|
||||
// A focused value can briefly outlive a closing scene. It must not route
|
||||
// Command+N back into a closed workspace.
|
||||
commands.newChatOrWindow(newChat: { chats += 1 }, openWindow: { windows += 1 })
|
||||
#expect(windows == 2)
|
||||
#expect(chats == 0)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func macNewChatCommandUsesExistingWorkspace() {
|
||||
let commands = SybilMacWindowCommands()
|
||||
commands.windowConnected(sessionID: "first")
|
||||
var chats = 0
|
||||
var windows = 0
|
||||
|
||||
#expect(commands.canStartNewChat(hasFocusedActions: true))
|
||||
commands.newChatOrWindow(newChat: { chats += 1 }, openWindow: { windows += 1 })
|
||||
#expect(chats == 1)
|
||||
#expect(windows == 0)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func macNewChatCommandDoesNotMistakeUnavailableActionsForNoWindows() {
|
||||
let commands = SybilMacWindowCommands()
|
||||
commands.windowConnected(sessionID: "authenticating")
|
||||
var windows = 0
|
||||
|
||||
#expect(!commands.canStartNewChat(hasFocusedActions: false))
|
||||
commands.newChatOrWindow(newChat: nil, openWindow: { windows += 1 })
|
||||
#expect(windows == 0)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func macWindowCommandsTrackLastWindowClosureAndReconnection() {
|
||||
let commands = SybilMacWindowCommands()
|
||||
commands.windowConnected(sessionID: "first")
|
||||
commands.windowConnected(sessionID: "first")
|
||||
commands.windowConnected(sessionID: "second")
|
||||
commands.windowDisconnected(sessionID: "first")
|
||||
#expect(commands.hasOpenWindows)
|
||||
commands.windowDisconnected(sessionID: "first")
|
||||
#expect(commands.hasOpenWindows)
|
||||
commands.windowDisconnected(sessionID: "second")
|
||||
#expect(!commands.hasOpenWindows)
|
||||
#expect(commands.canStartNewChat(hasFocusedActions: false))
|
||||
commands.windowConnected(sessionID: "third")
|
||||
#expect(commands.hasOpenWindows)
|
||||
#expect(!commands.canStartNewChat(hasFocusedActions: false))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockQuickQuestionPanel: NSObject, SybilQuickQuestionPanelBridging {
|
||||
private weak var delegate: (any SybilQuickQuestionPanelDelegate)?
|
||||
var isVisible = false
|
||||
var state = SybilQuickQuestionPanelState()
|
||||
var presentationCount = 0
|
||||
var activationCount = 0
|
||||
var stateUpdateCount = 0
|
||||
var events: [String] = []
|
||||
var onShow: (() -> Void)?
|
||||
|
||||
required override init() { super.init() }
|
||||
func start(delegate: any SybilQuickQuestionPanelDelegate) -> Int32 {
|
||||
self.delegate = delegate
|
||||
return 0
|
||||
}
|
||||
func updateState(_ data: Data) {
|
||||
state = try! JSONDecoder().decode(SybilQuickQuestionPanelState.self, from: data)
|
||||
stateUpdateCount += 1
|
||||
events.append("update")
|
||||
}
|
||||
func show() {
|
||||
onShow?()
|
||||
isVisible = true
|
||||
presentationCount += 1
|
||||
events.append("show")
|
||||
}
|
||||
func hide() {
|
||||
isVisible = false
|
||||
delegate?.quickQuestionPanelDismissed()
|
||||
}
|
||||
func activateForQuickQuestion() {
|
||||
activationCount += 1
|
||||
events.append("activate")
|
||||
}
|
||||
func activateMainWindow() {}
|
||||
func stop() { hide() }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func waitForQuickQuestion(_ condition: @MainActor () -> Bool) async throws {
|
||||
for _ in 0..<100 {
|
||||
if condition() { return }
|
||||
try await Task.sleep(for: .milliseconds(2))
|
||||
}
|
||||
try #require(condition())
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockQuickQuestionLifecycle {
|
||||
var isForeground = false
|
||||
let notifications = NotificationCenter()
|
||||
|
||||
func notify(_ name: Notification.Name) {
|
||||
notifications.post(name: name, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func macQuickQuestionWaitsForUIKitForegroundBeforeOpeningPanel() async throws {
|
||||
let lifecycle = MockQuickQuestionLifecycle()
|
||||
let panel = MockQuickQuestionPanel()
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in MockSybilClient() }
|
||||
viewModel.quickQuestionPrompt = "Original prompt"
|
||||
let controller = SybilMacQuickQuestionController(
|
||||
bridge: panel,
|
||||
isApplicationInForeground: { lifecycle.isForeground },
|
||||
notificationCenter: lifecycle.notifications
|
||||
)
|
||||
defer { controller.stop() }
|
||||
controller.attach(viewModel)
|
||||
panel.onShow = { #expect(lifecycle.isForeground) }
|
||||
|
||||
controller.toggle()
|
||||
// Never create/order a window inline in the hotkey's event callback.
|
||||
#expect(panel.presentationCount == 0)
|
||||
#expect(panel.activationCount == 0)
|
||||
try await waitForQuickQuestion { panel.activationCount == 1 }
|
||||
#expect(panel.stateUpdateCount == 0)
|
||||
#expect(!panel.isVisible)
|
||||
|
||||
// "Will enter" is not proof of readiness: UIKit can still be backgrounded.
|
||||
lifecycle.notify(UIApplication.willEnterForegroundNotification)
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
#expect(panel.activationCount == 1)
|
||||
#expect(panel.presentationCount == 0)
|
||||
#expect(panel.stateUpdateCount == 0)
|
||||
|
||||
viewModel.quickQuestionPrompt = "The latest prompt while waiting"
|
||||
lifecycle.isForeground = true
|
||||
lifecycle.notify(UIApplication.didBecomeActiveNotification)
|
||||
try await waitForQuickQuestion { panel.isVisible }
|
||||
#expect(panel.events == ["activate", "update", "show"])
|
||||
#expect(panel.state.prompt == "The latest prompt while waiting")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func macQuickQuestionSecondToggleCancelsPendingActivation() async throws {
|
||||
let lifecycle = MockQuickQuestionLifecycle()
|
||||
let panel = MockQuickQuestionPanel()
|
||||
let controller = SybilMacQuickQuestionController(
|
||||
bridge: panel,
|
||||
isApplicationInForeground: { lifecycle.isForeground },
|
||||
notificationCenter: lifecycle.notifications
|
||||
)
|
||||
defer { controller.stop() }
|
||||
controller.toggle()
|
||||
try await waitForQuickQuestion { panel.activationCount == 1 }
|
||||
controller.toggle()
|
||||
|
||||
lifecycle.isForeground = true
|
||||
lifecycle.notify(UIApplication.didBecomeActiveNotification)
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
#expect(!panel.isVisible)
|
||||
#expect(panel.presentationCount == 0)
|
||||
#expect(panel.stateUpdateCount == 0)
|
||||
|
||||
controller.toggle()
|
||||
try await waitForQuickQuestion { panel.isVisible }
|
||||
#expect(panel.presentationCount == 1)
|
||||
#expect(panel.activationCount == 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func macQuickQuestionRapidTogglesKeepOnlyLatestPresentation() async throws {
|
||||
let lifecycle = MockQuickQuestionLifecycle()
|
||||
lifecycle.isForeground = true
|
||||
let panel = MockQuickQuestionPanel()
|
||||
let controller = SybilMacQuickQuestionController(
|
||||
bridge: panel,
|
||||
isApplicationInForeground: { lifecycle.isForeground },
|
||||
notificationCenter: lifecycle.notifications
|
||||
)
|
||||
defer { controller.stop() }
|
||||
controller.toggle()
|
||||
controller.toggle()
|
||||
controller.toggle()
|
||||
#expect(panel.presentationCount == 0)
|
||||
try await waitForQuickQuestion { panel.isVisible }
|
||||
#expect(panel.presentationCount == 1)
|
||||
#expect(panel.activationCount == 0)
|
||||
|
||||
// Lifecycle notifications must not present/select the prompt a second time.
|
||||
lifecycle.notify(UIApplication.willEnterForegroundNotification)
|
||||
lifecycle.notify(UIApplication.didBecomeActiveNotification)
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
#expect(panel.presentationCount == 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func macQuickQuestionStopCancelsPendingPresentation() async throws {
|
||||
let lifecycle = MockQuickQuestionLifecycle()
|
||||
let panel = MockQuickQuestionPanel()
|
||||
let controller = SybilMacQuickQuestionController(
|
||||
bridge: panel,
|
||||
isApplicationInForeground: { lifecycle.isForeground },
|
||||
notificationCenter: lifecycle.notifications
|
||||
)
|
||||
controller.show()
|
||||
try await waitForQuickQuestion { panel.activationCount == 1 }
|
||||
controller.stop()
|
||||
lifecycle.isForeground = true
|
||||
lifecycle.notify(UIApplication.didBecomeActiveNotification)
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
#expect(panel.presentationCount == 0)
|
||||
#expect(panel.stateUpdateCount == 0)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func macQuickQuestionRechecksForegroundAfterLeavingHotKeyCallback() async throws {
|
||||
let lifecycle = MockQuickQuestionLifecycle()
|
||||
lifecycle.isForeground = true
|
||||
let panel = MockQuickQuestionPanel()
|
||||
let controller = SybilMacQuickQuestionController(
|
||||
bridge: panel,
|
||||
isApplicationInForeground: { lifecycle.isForeground },
|
||||
notificationCenter: lifecycle.notifications
|
||||
)
|
||||
defer { controller.stop() }
|
||||
controller.show()
|
||||
lifecycle.isForeground = false
|
||||
try await waitForQuickQuestion { panel.activationCount == 1 }
|
||||
#expect(!panel.isVisible)
|
||||
#expect(panel.stateUpdateCount == 0)
|
||||
lifecycle.isForeground = true
|
||||
lifecycle.notify(UIApplication.didBecomeActiveNotification)
|
||||
try await waitForQuickQuestion { panel.isVisible }
|
||||
#expect(panel.presentationCount == 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func macQuickQuestionTogglePreservesDraftAndUsesLatestAnswer() async throws {
|
||||
let client = MockSybilClient()
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.quickQuestionPrompt = "Keep this draft"
|
||||
let panel = MockQuickQuestionPanel()
|
||||
let controller = SybilMacQuickQuestionController(bridge: panel)
|
||||
controller.attach(viewModel)
|
||||
|
||||
controller.toggle()
|
||||
try await waitForQuickQuestion { panel.isVisible }
|
||||
#expect(panel.isVisible)
|
||||
#expect(panel.state.prompt == "Keep this draft")
|
||||
#expect(panel.state.canSend)
|
||||
controller.toggle()
|
||||
#expect(!panel.isVisible)
|
||||
|
||||
viewModel.quickQuestionMessages = [
|
||||
Message(id: "temp-assistant-quick-test", createdAt: Date(), role: .assistant, content: "An answer arrived while hidden.", name: nil)
|
||||
]
|
||||
controller.toggle()
|
||||
try await waitForQuickQuestion { panel.isVisible }
|
||||
#expect(panel.isVisible)
|
||||
#expect(panel.presentationCount == 2)
|
||||
#expect(panel.state.prompt == "Keep this draft")
|
||||
#expect(panel.state.answer == "An answer arrived while hidden.")
|
||||
let calls = await client.currentSnapshot()
|
||||
#expect(calls.runCompletionStream == 0)
|
||||
controller.stop()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func macQuickQuestionObservesStreamingAndKeepsItsOwner() async throws {
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in MockSybilClient() }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.quickQuestionPrompt = "Original window"
|
||||
let panel = MockQuickQuestionPanel()
|
||||
let controller = SybilMacQuickQuestionController(bridge: panel)
|
||||
controller.attach(viewModel)
|
||||
controller.show()
|
||||
|
||||
let otherWindow = SybilViewModel(settings: testSettings(named: #function + "-other")) { _ in MockSybilClient() }
|
||||
otherWindow.quickQuestionPrompt = "Other window"
|
||||
controller.attach(otherWindow)
|
||||
viewModel.quickQuestionMessages = [
|
||||
Message(id: "temp-assistant-quick-test", createdAt: Date(), role: .assistant, content: "Streaming update", name: nil)
|
||||
]
|
||||
for _ in 0..<30 {
|
||||
if panel.state.answer == "Streaming update" { break }
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
#expect(panel.state.answer == "Streaming update")
|
||||
#expect(panel.state.prompt == "Original window")
|
||||
controller.stop()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func macQuickQuestionRetainsStateAfterLastWorkspaceCloses() async throws {
|
||||
let client = MockSybilClient()
|
||||
var windowViewModel: SybilViewModel? = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
windowViewModel?.isAuthenticated = true
|
||||
windowViewModel?.isCheckingSession = false
|
||||
windowViewModel?.quickQuestionPrompt = "A question from the closed window"
|
||||
windowViewModel?.quickQuestionMessages = [
|
||||
Message(id: "temp-assistant-quick-windowless", createdAt: Date(), role: .assistant, content: "Keep this answer too.", name: nil)
|
||||
]
|
||||
let panel = MockQuickQuestionPanel()
|
||||
let controller = SybilMacQuickQuestionController(bridge: panel)
|
||||
controller.attach(try #require(windowViewModel))
|
||||
|
||||
// Releasing the workspace must not release the global shortcut's model.
|
||||
windowViewModel = nil
|
||||
controller.toggle()
|
||||
try await waitForQuickQuestion { panel.isVisible }
|
||||
#expect(panel.isVisible)
|
||||
#expect(panel.state.prompt == "A question from the closed window")
|
||||
#expect(panel.state.answer == "Keep this answer too.")
|
||||
controller.toggle()
|
||||
#expect(!panel.isVisible)
|
||||
controller.toggle()
|
||||
try await waitForQuickQuestion { panel.isVisible }
|
||||
#expect(panel.presentationCount == 2)
|
||||
#expect(panel.state.canSend)
|
||||
let calls = await client.currentSnapshot()
|
||||
#expect(calls.runCompletionStream == 0)
|
||||
controller.stop()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func macQuickQuestionCannotSubmitBeforeAuthentication() async throws {
|
||||
let client = MockSybilClient()
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = false
|
||||
viewModel.isCheckingSession = false
|
||||
let panel = MockQuickQuestionPanel()
|
||||
let controller = SybilMacQuickQuestionController(bridge: panel)
|
||||
controller.attach(viewModel)
|
||||
controller.show()
|
||||
try await waitForQuickQuestion { panel.isVisible }
|
||||
controller.quickQuestionPromptChanged("A signed-out question")
|
||||
controller.quickQuestionSubmitRequested()
|
||||
|
||||
#expect(!panel.state.canSend)
|
||||
#expect(panel.state.prompt == "A signed-out question")
|
||||
#expect(!viewModel.isQuickQuestionSending)
|
||||
let calls = await client.currentSnapshot()
|
||||
#expect(calls.runCompletionStream == 0)
|
||||
controller.stop()
|
||||
}
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user