Compare commits
3 Commits
29e340fd08
...
ios-pull-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9572d0320f | ||
| bca408c971 | |||
| 2f265fd847 |
@@ -9,5 +9,8 @@ struct SybilApp: App
|
|||||||
WindowGroup {
|
WindowGroup {
|
||||||
SplitView()
|
SplitView()
|
||||||
}
|
}
|
||||||
|
.commands {
|
||||||
|
SybilCommands()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ targets:
|
|||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
|
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
|
||||||
|
PRODUCT_NAME: Sybil
|
||||||
PRODUCT_MODULE_NAME: SybilApp
|
PRODUCT_MODULE_NAME: SybilApp
|
||||||
DEVELOPMENT_TEAM: DQQH5H6GBD
|
DEVELOPMENT_TEAM: DQQH5H6GBD
|
||||||
CODE_SIGN_STYLE: Automatic
|
CODE_SIGN_STYLE: Automatic
|
||||||
|
|||||||
@@ -7,6 +7,29 @@ public struct SplitView: View {
|
|||||||
@State private var shouldRefreshOnForeground = false
|
@State private var shouldRefreshOnForeground = false
|
||||||
@State private var composerFocusRequest = 0
|
@State private var composerFocusRequest = 0
|
||||||
|
|
||||||
|
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() {
|
@MainActor public init() {
|
||||||
SybilFontRegistry.registerIfNeeded()
|
SybilFontRegistry.registerIfNeeded()
|
||||||
SybilTheme.applySystemAppearance()
|
SybilTheme.applySystemAppearance()
|
||||||
@@ -41,6 +64,7 @@ public struct SplitView: View {
|
|||||||
}
|
}
|
||||||
.font(.sybil(.body))
|
.font(.sybil(.body))
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
|
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
||||||
.task {
|
.task {
|
||||||
await viewModel.bootstrap()
|
await viewModel.bootstrap()
|
||||||
}
|
}
|
||||||
@@ -67,3 +91,57 @@ public struct SplitView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct SybilCommands: Commands {
|
||||||
|
@FocusedValue(\.sybilKeyboardActions) private var keyboardActions
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public var body: some Commands {
|
||||||
|
CommandGroup(replacing: .newItem) {
|
||||||
|
Button("New Chat") {
|
||||||
|
keyboardActions?.newChat()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("n", modifiers: .command)
|
||||||
|
.disabled(keyboardActions == nil)
|
||||||
|
|
||||||
|
Button("New Search") {
|
||||||
|
keyboardActions?.newSearch()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("n", modifiers: [.command, .shift])
|
||||||
|
.disabled(keyboardActions == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
CommandMenu("Conversation") {
|
||||||
|
Button("Previous Conversation") {
|
||||||
|
keyboardActions?.previousConversation()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("[", modifiers: .command)
|
||||||
|
.disabled(keyboardActions == nil)
|
||||||
|
|
||||||
|
Button("Next Conversation") {
|
||||||
|
keyboardActions?.nextConversation()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("]", modifiers: .command)
|
||||||
|
.disabled(keyboardActions == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ struct SybilChatTranscriptView: View {
|
|||||||
var messages: [Message]
|
var messages: [Message]
|
||||||
var isLoading: Bool
|
var isLoading: Bool
|
||||||
var isSending: Bool
|
var isSending: Bool
|
||||||
|
var onRefresh: (() async -> Void)? = nil
|
||||||
@State private var hasHandledInitialTranscriptScroll = false
|
@State private var hasHandledInitialTranscriptScroll = false
|
||||||
|
|
||||||
private var hasPendingAssistant: Bool {
|
private var hasPendingAssistant: Bool {
|
||||||
@@ -51,6 +52,10 @@ struct SybilChatTranscriptView: View {
|
|||||||
.padding(.vertical, 18)
|
.padding(.vertical, 18)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.refreshable {
|
||||||
|
await onRefresh?()
|
||||||
|
}
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
scrollToBottom(with: proxy, animated: false)
|
scrollToBottom(with: proxy, animated: false)
|
||||||
|
|||||||
@@ -132,6 +132,10 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
}
|
}
|
||||||
.padding(10)
|
.padding(10)
|
||||||
}
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.refreshCollectionsFromUser()
|
||||||
|
}
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(SybilTheme.panelGradient)
|
.background(SybilTheme.panelGradient)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ struct SybilSearchResultsView: View {
|
|||||||
var isLoading: Bool
|
var isLoading: Bool
|
||||||
var isRunning: Bool
|
var isRunning: Bool
|
||||||
var isStartingChat: Bool = false
|
var isStartingChat: Bool = false
|
||||||
|
var onRefresh: (() async -> Void)? = nil
|
||||||
var onStartChat: (() -> Void)? = nil
|
var onStartChat: (() -> Void)? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -100,6 +101,10 @@ struct SybilSearchResultsView: View {
|
|||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 20)
|
.padding(.vertical, 20)
|
||||||
}
|
}
|
||||||
|
.refreshable {
|
||||||
|
await onRefresh?()
|
||||||
|
}
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,6 +149,10 @@ struct SybilSidebarView: View {
|
|||||||
}
|
}
|
||||||
.padding(10)
|
.padding(10)
|
||||||
}
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.refreshCollectionsFromUser()
|
||||||
|
}
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -443,6 +443,62 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func selectPreviousSidebarItem() {
|
||||||
|
selectAdjacentSidebarItem(offset: -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectNextSidebarItem() {
|
||||||
|
selectAdjacentSidebarItem(offset: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectAdjacentSidebarItem(offset: Int) {
|
||||||
|
let items = sidebarItems
|
||||||
|
guard !items.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentIndex = selectedItem.flatMap { selection in
|
||||||
|
items.firstIndex { $0.selection == selection }
|
||||||
|
}
|
||||||
|
let startingIndex = currentIndex ?? (offset < 0 ? items.count : -1)
|
||||||
|
let nextIndex = (startingIndex + offset + items.count) % items.count
|
||||||
|
let nextSelection = items[nextIndex].selection
|
||||||
|
|
||||||
|
guard draftKind != nil || selectedItem != nextSelection else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
select(nextSelection)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshCollectionsFromUser() async {
|
||||||
|
guard isAuthenticated else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
guard draftKind == nil else {
|
||||||
|
await refreshCollectionsPreservingDraft()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshCollections(preferredSelection: selectedItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshSelectionFromUser() async {
|
||||||
|
guard isAuthenticated, !isSending, !isCreatingSearchChat else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard selectedItem != nil, draftKind == nil else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessage = nil
|
||||||
|
await refreshSelectionIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
func deleteItem(_ selection: SidebarSelection) async {
|
func deleteItem(_ selection: SidebarSelection) async {
|
||||||
guard isAuthenticated else {
|
guard isAuthenticated else {
|
||||||
return
|
return
|
||||||
@@ -682,6 +738,30 @@ final class SybilViewModel {
|
|||||||
settings.persist()
|
settings.persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func refreshCollectionsPreservingDraft() async {
|
||||||
|
isLoadingCollections = true
|
||||||
|
|
||||||
|
do {
|
||||||
|
let client = try client()
|
||||||
|
async let chatsValue = client.listChats()
|
||||||
|
async let searchesValue = client.listSearches()
|
||||||
|
let (nextChats, nextSearches) = try await (chatsValue, searchesValue)
|
||||||
|
|
||||||
|
chats = nextChats
|
||||||
|
searches = nextSearches
|
||||||
|
|
||||||
|
SybilLog.info(
|
||||||
|
SybilLog.app,
|
||||||
|
"Refreshed collections for draft: \(nextChats.count) chats, \(nextSearches.count) searches"
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
errorMessage = normalizeAPIError(error)
|
||||||
|
SybilLog.error(SybilLog.app, "Refresh draft collections failed", error: error)
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingCollections = false
|
||||||
|
}
|
||||||
|
|
||||||
private func refreshCollections(
|
private func refreshCollections(
|
||||||
preferredSelection: SidebarSelection?,
|
preferredSelection: SidebarSelection?,
|
||||||
refreshSelection: Bool = true
|
refreshSelection: Bool = true
|
||||||
|
|||||||
@@ -127,7 +127,10 @@ struct SybilWorkspaceView: View {
|
|||||||
search: viewModel.selectedSearch,
|
search: viewModel.selectedSearch,
|
||||||
isLoading: viewModel.isLoadingSelection,
|
isLoading: viewModel.isLoadingSelection,
|
||||||
isRunning: viewModel.isSending,
|
isRunning: viewModel.isSending,
|
||||||
isStartingChat: viewModel.isCreatingSearchChat
|
isStartingChat: viewModel.isCreatingSearchChat,
|
||||||
|
onRefresh: {
|
||||||
|
await viewModel.refreshSelectionFromUser()
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.startChatFromSelectedSearch()
|
await viewModel.startChatFromSelectedSearch()
|
||||||
@@ -137,7 +140,10 @@ struct SybilWorkspaceView: View {
|
|||||||
SybilChatTranscriptView(
|
SybilChatTranscriptView(
|
||||||
messages: viewModel.displayedMessages,
|
messages: viewModel.displayedMessages,
|
||||||
isLoading: viewModel.isLoadingSelection,
|
isLoading: viewModel.isLoadingSelection,
|
||||||
isSending: viewModel.isSending
|
isSending: viewModel.isSending,
|
||||||
|
onRefresh: {
|
||||||
|
await viewModel.refreshSelectionFromUser()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.id(transcriptScrollContextID)
|
.id(transcriptScrollContextID)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user