Files
Sybil-2/ios/Packages/Sybil/Sources/Sybil/SplitView.swift
T

273 lines
9.5 KiB
Swift
Raw Normal View History

2026-02-19 22:38:45 -08:00
import SwiftUI
2026-02-20 00:09:02 -08:00
public struct SplitView: View {
@State private var viewModel = SybilViewModel()
2026-05-06 21:53:51 -07:00
@ObservedObject private var quickActionRouter = SybilQuickActionRouter.shared
2026-02-20 00:09:02 -08:00
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
2026-05-02 22:18:33 -07:00
@Environment(\.scenePhase) private var scenePhase
@State private var shouldRefreshOnForeground = false
2026-05-02 22:46:25 -07:00
@State private var composerFocusRequest = 0
2026-05-06 21:53:51 -07:00
@State private var quickQuestionFocusRequest = 0
@State private var hasPendingQuickQuestionPresentation = false
@State private var isQuickQuestionPresented = false
2026-05-03 18:38:16 -07:00
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
2026-02-20 00:09:02 -08:00
2026-05-02 23:50:51 -07:00
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()
}
)
}
2026-05-02 17:10:32 -07:00
@MainActor public init() {
2026-05-02 16:23:00 -07:00
SybilFontRegistry.registerIfNeeded()
2026-05-02 17:10:32 -07:00
SybilTheme.applySystemAppearance()
2026-05-02 16:23:00 -07:00
}
2026-02-20 00:09:02 -08:00
2026-02-19 22:38:45 -08:00
public var body: some View {
2026-02-20 00:09:02 -08:00
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 {
2026-05-03 18:38:16 -07:00
GeometryReader { proxy in
NavigationSplitView(columnVisibility: $columnVisibility) {
SybilSidebarView(viewModel: viewModel)
} detail: {
SybilWorkspaceView(
viewModel: viewModel,
composerFocusRequest: composerFocusRequest,
navigationLeadingControl: splitNavigationLeadingControl(for: proxy.size),
2026-05-03 21:14:10 -07:00
onShowSidebar: showSidebar,
onRequestNewChat: {
viewModel.startNewChat()
composerFocusRequest += 1
}
)
2026-05-02 22:46:25 -07:00
}
2026-05-03 18:38:16 -07:00
.navigationSplitViewStyle(.balanced)
.tint(SybilTheme.primary)
2026-02-20 00:09:02 -08:00
}
}
}
2026-05-02 16:23:00 -07:00
.font(.sybil(.body))
2026-05-02 17:10:32 -07:00
.preferredColorScheme(.dark)
2026-05-02 23:50:51 -07:00
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
#if !targetEnvironment(macCatalyst)
2026-05-06 21:53:51 -07:00
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
SybilQuickQuestionView(
viewModel: viewModel,
focusRequest: quickQuestionFocusRequest
)
.presentationDragIndicator(.visible)
}
#endif
2026-02-20 00:09:02 -08:00
.task {
#if targetEnvironment(macCatalyst)
SybilMacQuickQuestionController.shared.attach(viewModel)
#endif
2026-02-20 00:09:02 -08:00
await viewModel.bootstrap()
2026-05-06 21:53:51 -07:00
presentPendingQuickQuestionIfPossible()
}
.onReceive(quickActionRouter.$quickQuestionPresentationRequest) { request in
guard request > 0 else {
return
}
queueQuickQuestionPresentation()
}
.onChange(of: viewModel.isCheckingSession) { _, _ in
presentPendingQuickQuestionIfPossible()
}
.onChange(of: viewModel.isAuthenticated) { _, _ in
presentPendingQuickQuestionIfPossible()
2026-02-20 00:09:02 -08:00
}
2026-10-14 13:34:37 -07:00
.onChange(of: isQuickQuestionPresented) { _, presented in
viewModel.agent.isModalPresented = presented
}
2026-05-02 22:18:33 -07:00
.onChange(of: scenePhase) { _, nextPhase in
switch nextPhase {
case .background:
shouldRefreshOnForeground = true
2026-05-03 16:42:49 -07:00
viewModel.markAppInactiveForNetwork()
2026-05-02 22:18:33 -07:00
case .active:
#if targetEnvironment(macCatalyst)
SybilMacQuickQuestionController.shared.attach(viewModel)
#endif
2026-05-03 16:42:49 -07:00
viewModel.markAppActiveForNetwork()
2026-05-02 22:18:33 -07:00
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
return
}
shouldRefreshOnForeground = false
Task {
2026-05-03 16:42:49 -07:00
await viewModel.refreshAfterAppBecameActive(
2026-05-02 22:18:33 -07:00
refreshCollections: true,
refreshSelection: viewModel.hasRefreshableSelection
)
}
case .inactive:
2026-05-03 16:42:49 -07:00
shouldRefreshOnForeground = true
viewModel.markAppInactiveForNetwork()
2026-05-02 22:18:33 -07:00
@unknown default:
break
}
}
2026-02-19 22:38:45 -08:00
}
2026-05-03 18:38:16 -07:00
private func splitNavigationLeadingControl(for size: CGSize) -> SybilWorkspaceNavigationLeadingControl {
return size.width < size.height ? .showSidebar : .hidden
}
private func showSidebar() {
withAnimation(.easeInOut(duration: 0.22)) {
columnVisibility = .all
}
}
2026-05-06 21:53:51 -07:00
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
2026-05-06 21:53:51 -07:00
quickQuestionFocusRequest += 1
isQuickQuestionPresented = true
#endif
2026-05-06 21:53:51 -07:00
}
private func handleQuickQuestionDismissed() {
viewModel.cancelQuickQuestion()
}
2026-02-19 22:38:45 -08:00
}
2026-05-02 23:50:51 -07:00
public struct SybilCommands: Commands {
public static let workspaceWindowID = "sybil-workspace"
2026-05-02 23:50:51 -07:00
@FocusedValue(\.sybilKeyboardActions) private var keyboardActions
#if targetEnvironment(macCatalyst)
@Environment(\.openWindow) private var openWindow
@ObservedObject private var windowCommands = SybilMacWindowCommands.shared
#endif
2026-05-02 23:50:51 -07:00
public init() {}
public var body: some Commands {
CommandGroup(replacing: .newItem) {
Button("New Chat") {
#if targetEnvironment(macCatalyst)
windowCommands.newChatOrWindow(
newChat: keyboardActions?.newChat,
openWindow: openNewWindow
)
#else
2026-05-02 23:50:51 -07:00
keyboardActions?.newChat()
#endif
2026-05-02 23:50:51 -07:00
}
.keyboardShortcut("n", modifiers: .command)
#if targetEnvironment(macCatalyst)
.disabled(!windowCommands.canStartNewChat(hasFocusedActions: keyboardActions != nil))
#else
2026-05-02 23:50:51 -07:00
.disabled(keyboardActions == nil)
#endif
#if targetEnvironment(macCatalyst)
Button("New Window", action: openNewWindow)
.keyboardShortcut("n", modifiers: [.command, .shift])
#endif
2026-05-02 23:50:51 -07:00
Button("New Search") {
keyboardActions?.newSearch()
}
#if targetEnvironment(macCatalyst)
.keyboardShortcut("n", modifiers: [.command, .option])
#else
2026-05-02 23:50:51 -07:00
.keyboardShortcut("n", modifiers: [.command, .shift])
#endif
2026-05-02 23:50:51 -07:00
.disabled(keyboardActions == nil)
#if targetEnvironment(macCatalyst)
Divider()
Button("Quick Question") {
SybilMacQuickQuestionController.shared.toggle()
}
.keyboardShortcut(.space, modifiers: .option)
#endif
2026-05-02 23:50:51 -07:00
}
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
2026-05-02 23:50:51 -07:00
}
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 }
}
}