3 Commits

Author SHA1 Message Date
Sybil Codex
9572d0320f ios: add pull to refresh 2026-05-03 19:27:06 +00:00
bca408c971 ios: specify product name 2026-05-03 00:17:08 -07:00
2f265fd847 ios: mac catalyst kb shortcuts 2026-05-02 23:50:51 -07:00
9 changed files with 188 additions and 2 deletions

View File

@@ -9,5 +9,8 @@ struct SybilApp: App
WindowGroup {
SplitView()
}
.commands {
SybilCommands()
}
}
}

View File

@@ -14,6 +14,7 @@ targets:
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
PRODUCT_NAME: Sybil
PRODUCT_MODULE_NAME: SybilApp
DEVELOPMENT_TEAM: DQQH5H6GBD
CODE_SIGN_STYLE: Automatic

View File

@@ -7,6 +7,29 @@ public struct SplitView: View {
@State private var shouldRefreshOnForeground = false
@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() {
SybilFontRegistry.registerIfNeeded()
SybilTheme.applySystemAppearance()
@@ -41,6 +64,7 @@ public struct SplitView: View {
}
.font(.sybil(.body))
.preferredColorScheme(.dark)
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
.task {
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 }
}
}

View File

@@ -5,6 +5,7 @@ struct SybilChatTranscriptView: View {
var messages: [Message]
var isLoading: Bool
var isSending: Bool
var onRefresh: (() async -> Void)? = nil
@State private var hasHandledInitialTranscriptScroll = false
private var hasPendingAssistant: Bool {
@@ -51,6 +52,10 @@ struct SybilChatTranscriptView: View {
.padding(.vertical, 18)
}
.frame(maxWidth: .infinity, alignment: .leading)
.refreshable {
await onRefresh?()
}
.tint(SybilTheme.primary)
.scrollDismissesKeyboard(.interactively)
.onAppear {
scrollToBottom(with: proxy, animated: false)

View File

@@ -132,6 +132,10 @@ private struct SybilPhoneSidebarRoot: View {
}
.padding(10)
}
.refreshable {
await viewModel.refreshCollectionsFromUser()
}
.tint(SybilTheme.primary)
}
}
.background(SybilTheme.panelGradient)

View File

@@ -6,6 +6,7 @@ struct SybilSearchResultsView: View {
var isLoading: Bool
var isRunning: Bool
var isStartingChat: Bool = false
var onRefresh: (() async -> Void)? = nil
var onStartChat: (() -> Void)? = nil
var body: some View {
@@ -100,6 +101,10 @@ struct SybilSearchResultsView: View {
.padding(.horizontal, 14)
.padding(.vertical, 20)
}
.refreshable {
await onRefresh?()
}
.tint(SybilTheme.primary)
.scrollDismissesKeyboard(.interactively)
.frame(maxWidth: .infinity, alignment: .leading)
}

View File

@@ -149,6 +149,10 @@ struct SybilSidebarView: View {
}
.padding(10)
}
.refreshable {
await viewModel.refreshCollectionsFromUser()
}
.tint(SybilTheme.primary)
}
}

View File

@@ -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 {
guard isAuthenticated else {
return
@@ -682,6 +738,30 @@ final class SybilViewModel {
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(
preferredSelection: SidebarSelection?,
refreshSelection: Bool = true

View File

@@ -127,7 +127,10 @@ struct SybilWorkspaceView: View {
search: viewModel.selectedSearch,
isLoading: viewModel.isLoadingSelection,
isRunning: viewModel.isSending,
isStartingChat: viewModel.isCreatingSearchChat
isStartingChat: viewModel.isCreatingSearchChat,
onRefresh: {
await viewModel.refreshSelectionFromUser()
}
) {
Task {
await viewModel.startChatFromSelectedSearch()
@@ -137,7 +140,10 @@ struct SybilWorkspaceView: View {
SybilChatTranscriptView(
messages: viewModel.displayedMessages,
isLoading: viewModel.isLoadingSelection,
isSending: viewModel.isSending
isSending: viewModel.isSending,
onRefresh: {
await viewModel.refreshSelectionFromUser()
}
)
.id(transcriptScrollContextID)
}