291 lines
11 KiB
Swift
291 lines
11 KiB
Swift
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
|
|
@State private var notificationSceneID = UUID()
|
|
|
|
private var keyboardActions: SybilKeyboardActions? {
|
|
guard !viewModel.isCheckingSession, viewModel.isAuthenticated else {
|
|
return nil
|
|
}
|
|
|
|
return SybilKeyboardActions(
|
|
newChat: {
|
|
viewModel.startNewChat()
|
|
composerFocusRequest += 1
|
|
},
|
|
newSearch: {
|
|
viewModel.startNewSearch()
|
|
composerFocusRequest += 1
|
|
},
|
|
previousConversation: {
|
|
viewModel.selectPreviousSidebarItem()
|
|
},
|
|
nextConversation: {
|
|
viewModel.selectNextSidebarItem()
|
|
}
|
|
)
|
|
}
|
|
|
|
@MainActor public init() {
|
|
SybilFontRegistry.registerIfNeeded()
|
|
SybilTheme.applySystemAppearance()
|
|
}
|
|
|
|
public var body: some View {
|
|
ZStack {
|
|
SybilTheme.backgroundGradient
|
|
.ignoresSafeArea()
|
|
|
|
if viewModel.isCheckingSession {
|
|
ProgressView("Checking session…")
|
|
.tint(SybilTheme.primary)
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
} else if !viewModel.isAuthenticated {
|
|
SybilConnectionView(viewModel: viewModel)
|
|
.padding()
|
|
} else if horizontalSizeClass == .compact {
|
|
SybilPhoneShellView(viewModel: viewModel)
|
|
} else {
|
|
GeometryReader { proxy in
|
|
NavigationSplitView(columnVisibility: $columnVisibility) {
|
|
SybilSidebarView(viewModel: viewModel)
|
|
} detail: {
|
|
SybilWorkspaceView(
|
|
viewModel: viewModel,
|
|
composerFocusRequest: composerFocusRequest,
|
|
navigationLeadingControl: splitNavigationLeadingControl(for: proxy.size),
|
|
onShowSidebar: showSidebar,
|
|
onRequestNewChat: {
|
|
viewModel.startNewChat()
|
|
composerFocusRequest += 1
|
|
}
|
|
)
|
|
}
|
|
.navigationSplitViewStyle(.balanced)
|
|
.tint(SybilTheme.primary)
|
|
}
|
|
}
|
|
}
|
|
.font(.sybil(.body))
|
|
.preferredColorScheme(.dark)
|
|
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
|
#if !targetEnvironment(macCatalyst)
|
|
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
|
|
SybilQuickQuestionView(
|
|
viewModel: viewModel,
|
|
focusRequest: quickQuestionFocusRequest
|
|
)
|
|
.presentationDragIndicator(.visible)
|
|
}
|
|
#endif
|
|
.task {
|
|
#if targetEnvironment(macCatalyst)
|
|
SybilMacQuickQuestionController.shared.attach(viewModel)
|
|
#endif
|
|
await viewModel.bootstrap()
|
|
SybilPushNotifications.foregroundFixtureIfRequested()
|
|
viewModel.notifications.setScene(notificationSceneID, active: scenePhase == .active)
|
|
routePendingNotification()
|
|
presentPendingQuickQuestionIfPossible()
|
|
}
|
|
.onChange(of: viewModel.notifications.pendingRouteID) { _, _ in routePendingNotification() }
|
|
.onChange(of: viewModel.notifications.scope) { _, _ in routePendingNotification() }
|
|
.onDisappear { viewModel.notifications.setScene(notificationSceneID, active: false) }
|
|
.onReceive(quickActionRouter.$quickQuestionPresentationRequest) { request in
|
|
guard request > 0 else {
|
|
return
|
|
}
|
|
queueQuickQuestionPresentation()
|
|
}
|
|
.onChange(of: viewModel.isCheckingSession) { _, _ in
|
|
routePendingNotification()
|
|
presentPendingQuickQuestionIfPossible()
|
|
}
|
|
.onChange(of: viewModel.isAuthenticated) { _, _ in
|
|
presentPendingQuickQuestionIfPossible()
|
|
}
|
|
.onChange(of: isQuickQuestionPresented) { _, presented in
|
|
viewModel.agent.isModalPresented = presented
|
|
}
|
|
.onChange(of: scenePhase) { _, nextPhase in
|
|
viewModel.notifications.setScene(notificationSceneID, active: nextPhase == .active)
|
|
switch nextPhase {
|
|
case .background:
|
|
shouldRefreshOnForeground = true
|
|
viewModel.markAppInactiveForNetwork()
|
|
case .active:
|
|
SybilPushNotifications.foregroundFixtureIfRequested()
|
|
routePendingNotification()
|
|
#if targetEnvironment(macCatalyst)
|
|
SybilMacQuickQuestionController.shared.attach(viewModel)
|
|
#endif
|
|
viewModel.markAppActiveForNetwork()
|
|
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
|
|
return
|
|
}
|
|
shouldRefreshOnForeground = false
|
|
Task {
|
|
await viewModel.refreshAfterAppBecameActive(
|
|
refreshCollections: true,
|
|
refreshSelection: viewModel.hasRefreshableSelection
|
|
)
|
|
}
|
|
case .inactive:
|
|
shouldRefreshOnForeground = true
|
|
viewModel.markAppInactiveForNetwork()
|
|
@unknown default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
private func routePendingNotification() {
|
|
guard scenePhase == .active, viewModel.consumeAgentNotificationRoute() else { return }
|
|
hasPendingQuickQuestionPresentation = false
|
|
isQuickQuestionPresented = false
|
|
columnVisibility = .detailOnly
|
|
}
|
|
|
|
private func splitNavigationLeadingControl(for size: CGSize) -> SybilWorkspaceNavigationLeadingControl {
|
|
return size.width < size.height ? .showSidebar : .hidden
|
|
}
|
|
|
|
private func showSidebar() {
|
|
withAnimation(.easeInOut(duration: 0.22)) {
|
|
columnVisibility = .all
|
|
}
|
|
}
|
|
|
|
private func queueQuickQuestionPresentation() {
|
|
hasPendingQuickQuestionPresentation = true
|
|
presentPendingQuickQuestionIfPossible()
|
|
}
|
|
|
|
private func presentPendingQuickQuestionIfPossible() {
|
|
guard hasPendingQuickQuestionPresentation,
|
|
!viewModel.isCheckingSession,
|
|
viewModel.isAuthenticated
|
|
else {
|
|
return
|
|
}
|
|
|
|
hasPendingQuickQuestionPresentation = false
|
|
#if targetEnvironment(macCatalyst)
|
|
SybilMacQuickQuestionController.shared.attach(viewModel)
|
|
SybilMacQuickQuestionController.shared.show()
|
|
#else
|
|
quickQuestionFocusRequest += 1
|
|
isQuickQuestionPresented = true
|
|
#endif
|
|
}
|
|
|
|
private func handleQuickQuestionDismissed() {
|
|
viewModel.cancelQuickQuestion()
|
|
}
|
|
}
|
|
|
|
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") {
|
|
Button("Previous Conversation") {
|
|
keyboardActions?.previousConversation()
|
|
}
|
|
.keyboardShortcut("[", modifiers: .command)
|
|
.disabled(keyboardActions == nil)
|
|
|
|
Button("Next Conversation") {
|
|
keyboardActions?.nextConversation()
|
|
}
|
|
.keyboardShortcut("]", modifiers: .command)
|
|
.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 {
|
|
var newChat: () -> Void
|
|
var newSearch: () -> Void
|
|
var previousConversation: () -> Void
|
|
var nextConversation: () -> Void
|
|
}
|
|
|
|
private struct SybilKeyboardActionsKey: FocusedValueKey {
|
|
typealias Value = SybilKeyboardActions
|
|
}
|
|
|
|
private extension FocusedValues {
|
|
var sybilKeyboardActions: SybilKeyboardActions? {
|
|
get { self[SybilKeyboardActionsKey.self] }
|
|
set { self[SybilKeyboardActionsKey.self] = newValue }
|
|
}
|
|
}
|