4 Commits

Author SHA1 Message Date
29c6dce0e5 ios: show provider/model in subtitle 2026-05-09 21:19:34 -07:00
5855b7edb8 ios: keyboard dismissal behavior 2026-05-09 20:49:27 -07:00
ac6d55f617 ios: 1.9 2026-05-06 22:35:00 -07:00
1e045db7f4 ios: fix pull to refresh 2026-05-06 22:34:17 -07:00
6 changed files with 96 additions and 17 deletions

View File

@@ -24,8 +24,8 @@ targets:
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
INFOPLIST_FILE: Apps/Sybil/Info.plist INFOPLIST_FILE: Apps/Sybil/Info.plist
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
MARKETING_VERSION: 1.7 MARKETING_VERSION: 1.8
CURRENT_PROJECT_VERSION: 8 CURRENT_PROJECT_VERSION: 9
INFOPLIST_KEY_CFBundleDisplayName: Sybil INFOPLIST_KEY_CFBundleDisplayName: Sybil
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES

View File

@@ -219,6 +219,11 @@ struct SybilQuickQuestionView: View {
} }
private func submitQuestion() { private func submitQuestion() {
guard viewModel.canSendQuickQuestion else {
return
}
promptFocused = false
_ = viewModel.sendQuickQuestion() _ = viewModel.sendQuickQuestion()
} }
} }

View File

@@ -159,10 +159,7 @@ struct SybilSidebarItemList: View {
.padding(10) .padding(10)
} }
.refreshable { .refreshable {
await viewModel.refreshVisibleContent( await viewModel.refreshSidebarCollectionsFromPullToRefresh()
refreshCollections: true,
refreshSelection: false
)
} }
} }
} }

View File

@@ -911,6 +911,23 @@ final class SybilViewModel {
} }
} }
func refreshSidebarCollectionsFromPullToRefresh() async {
guard isAuthenticated, !isCheckingSession else {
return
}
SybilLog.info(
SybilLog.ui,
"Sidebar pull-to-refresh requested"
)
let preferredSelection = selectedItem
let refreshTask = Task { @MainActor in
await refreshCollections(preferredSelection: preferredSelection, refreshSelection: false)
}
await refreshTask.value
}
func sendComposer() async { func sendComposer() async {
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines) let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
let attachments = composerAttachments let attachments = composerAttachments
@@ -1276,7 +1293,9 @@ final class SybilViewModel {
attachToVisibleActiveRunIfNeeded() attachToVisibleActiveRunIfNeeded()
} }
} catch { } catch {
if shouldSuppressInactiveTransportError(error) { if isCancellation(error) {
SybilLog.debug(SybilLog.app, "Collection refresh cancelled")
} else if shouldSuppressInactiveTransportError(error) {
SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive") SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive")
} else { } else {
errorMessage = normalizeAPIError(error) errorMessage = normalizeAPIError(error)

View File

@@ -232,13 +232,7 @@ struct SybilWorkspaceView: View {
HStack(spacing: 14) { HStack(spacing: 14) {
workspaceNavigationLeadingControl workspaceNavigationLeadingControl
Text(viewModel.selectedTitle) customWorkspaceNavigationTitle
.font(.sybil(size: 16, weight: .semibold))
.foregroundStyle(SybilTheme.text)
.lineLimit(1)
.minimumScaleFactor(0.78)
.frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(.leading)
workspaceNavigationTrailingControl workspaceNavigationTrailingControl
} }
@@ -251,6 +245,32 @@ struct SybilWorkspaceView: View {
} }
} }
private var selectedProviderModelSubtitle: String {
let selectedModel = viewModel.model.trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedModel.isEmpty else {
return viewModel.provider.displayName
}
return "\(viewModel.provider.displayName)\(selectedModel)"
}
private var customWorkspaceNavigationTitle: some View {
VStack(alignment: .leading, spacing: 2) {
Text(viewModel.selectedTitle)
.font(.sybil(size: 16, weight: .semibold))
.foregroundStyle(SybilTheme.text)
.lineLimit(1)
.minimumScaleFactor(0.78)
Text(selectedProviderModelSubtitle)
.font(.sybil(size: 10, weight: .medium))
.foregroundStyle(SybilTheme.textMuted)
.lineLimit(1)
.minimumScaleFactor(0.82)
}
.frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(.leading)
}
@ViewBuilder @ViewBuilder
private var workspaceNavigationLeadingControl: some View { private var workspaceNavigationLeadingControl: some View {
switch navigationLeadingControl { switch navigationLeadingControl {
@@ -703,9 +723,7 @@ struct SybilWorkspaceView: View {
} }
#if !targetEnvironment(macCatalyst) #if !targetEnvironment(macCatalyst)
if !viewModel.isSearchMode { composerFocused = false
composerFocused = false
}
#endif #endif
Task { Task {

View File

@@ -36,6 +36,8 @@ private actor MockSybilClient: SybilAPIClienting {
private var lastCreateChatCall: ChatCreateCallSnapshot? private var lastCreateChatCall: ChatCreateCallSnapshot?
private var lastCompletionStreamBody: CompletionStreamRequest? private var lastCompletionStreamBody: CompletionStreamRequest?
private var completionStreamEvents: [CompletionStreamEvent]? private var completionStreamEvents: [CompletionStreamEvent]?
private var listChatsDelayNanoseconds: UInt64 = 0
private var listSearchesDelayNanoseconds: UInt64 = 0
private var getChatDelayNanoseconds: UInt64 = 0 private var getChatDelayNanoseconds: UInt64 = 0
private var getSearchDelayNanoseconds: UInt64 = 0 private var getSearchDelayNanoseconds: UInt64 = 0
private var completionStreamNetworkErrorMessage: String? private var completionStreamNetworkErrorMessage: String?
@@ -85,6 +87,11 @@ private actor MockSybilClient: SybilAPIClienting {
completionStreamDelayNanoseconds = delayNanoseconds completionStreamDelayNanoseconds = delayNanoseconds
} }
func setListDelays(chats: UInt64 = 0, searches: UInt64 = 0) {
listChatsDelayNanoseconds = chats
listSearchesDelayNanoseconds = searches
}
func setGetChatDelay(_ delayNanoseconds: UInt64) { func setGetChatDelay(_ delayNanoseconds: UInt64) {
getChatDelayNanoseconds = delayNanoseconds getChatDelayNanoseconds = delayNanoseconds
} }
@@ -122,6 +129,9 @@ private actor MockSybilClient: SybilAPIClienting {
func listChats() async throws -> [ChatSummary] { func listChats() async throws -> [ChatSummary] {
snapshot.listChats += 1 snapshot.listChats += 1
if listChatsDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: listChatsDelayNanoseconds)
}
return chatsResponse return chatsResponse
} }
@@ -165,6 +175,9 @@ private actor MockSybilClient: SybilAPIClienting {
func listSearches() async throws -> [SearchSummary] { func listSearches() async throws -> [SearchSummary] {
snapshot.listSearches += 1 snapshot.listSearches += 1
if listSearchesDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: listSearchesDelayNanoseconds)
}
return searchesResponse return searchesResponse
} }
@@ -383,6 +396,33 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
#expect(viewModel.selectedItem == .chat("chat-1")) #expect(viewModel.selectedItem == .chat("chat-1"))
} }
@MainActor
@Test func pullToRefreshCompletesWhenRefreshableTaskIsCancelled() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_050)
let chat = makeChatSummary(id: "chat-cancelled", date: date)
let search = makeSearchSummary(id: "search-cancelled", date: date)
let client = MockSybilClient(
chatsResponse: [chat],
searchesResponse: [search]
)
await client.setListDelays(chats: 50_000_000, searches: 50_000_000)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
let refreshTask = Task {
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
}
try await Task.sleep(nanoseconds: 10_000_000)
refreshTask.cancel()
await refreshTask.value
#expect(viewModel.errorMessage == nil)
#expect(!viewModel.isLoadingCollections)
#expect(viewModel.chats.map(\.id) == ["chat-cancelled"])
#expect(viewModel.searches.map(\.id) == ["search-cancelled"])
}
@MainActor @MainActor
@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws { @Test func foregroundChatRefreshReloadsSelectedTranscript() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_100) let date = Date(timeIntervalSince1970: 1_700_000_100)