adds quick question UI for mac catalyst target
This commit is contained in:
@@ -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