TestFlight / Build and upload (push) Successful in 5m29s
Integrate the shared Agent room, durable outbox, cursor replay and read state on iPhone and iPad. Reuse message rendering with native messaging layout and expandable tool details. Validated with 58 Xcode package tests, six Simulator UI scenarios, read-only production connectivity and independent review.
273 lines
9.5 KiB
Swift
273 lines
9.5 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
|
|
|
|
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()
|
|
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: isQuickQuestionPresented) { _, presented in
|
|
viewModel.agent.isModalPresented = presented
|
|
}
|
|
.onChange(of: scenePhase) { _, nextPhase in
|
|
switch nextPhase {
|
|
case .background:
|
|
shouldRefreshOnForeground = true
|
|
viewModel.markAppInactiveForNetwork()
|
|
case .active:
|
|
#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 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 }
|
|
}
|
|
}
|