Compare commits
1 Commits
ae783020ef
...
ios-pull-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9572d0320f |
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB |
@@ -72,22 +72,19 @@ public struct SplitView: View {
|
|||||||
switch nextPhase {
|
switch nextPhase {
|
||||||
case .background:
|
case .background:
|
||||||
shouldRefreshOnForeground = true
|
shouldRefreshOnForeground = true
|
||||||
viewModel.markAppInactiveForNetwork()
|
|
||||||
case .active:
|
case .active:
|
||||||
viewModel.markAppActiveForNetwork()
|
|
||||||
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
|
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
shouldRefreshOnForeground = false
|
shouldRefreshOnForeground = false
|
||||||
Task {
|
Task {
|
||||||
await viewModel.refreshAfterAppBecameActive(
|
await viewModel.refreshVisibleContent(
|
||||||
refreshCollections: true,
|
refreshCollections: true,
|
||||||
refreshSelection: viewModel.hasRefreshableSelection
|
refreshSelection: viewModel.hasRefreshableSelection
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case .inactive:
|
case .inactive:
|
||||||
shouldRefreshOnForeground = true
|
break
|
||||||
viewModel.markAppInactiveForNetwork()
|
|
||||||
@unknown default:
|
@unknown default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ struct SybilChatTranscriptView: View {
|
|||||||
var messages: [Message]
|
var messages: [Message]
|
||||||
var isLoading: Bool
|
var isLoading: Bool
|
||||||
var isSending: Bool
|
var isSending: Bool
|
||||||
var topContentInset: CGFloat = 0
|
var onRefresh: (() async -> Void)? = nil
|
||||||
@State private var hasHandledInitialTranscriptScroll = false
|
@State private var hasHandledInitialTranscriptScroll = false
|
||||||
|
|
||||||
private var hasPendingAssistant: Bool {
|
private var hasPendingAssistant: Bool {
|
||||||
@@ -49,10 +49,13 @@ struct SybilChatTranscriptView: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.top, 18 + topContentInset)
|
.padding(.vertical, 18)
|
||||||
.padding(.bottom, 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)
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ extension Theme {
|
|||||||
.paragraph { configuration in
|
.paragraph { configuration in
|
||||||
configuration.label
|
configuration.label
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.relativeLineSpacing(.em(0.46))
|
.relativeLineSpacing(.em(0.36))
|
||||||
.markdownMargin(top: .zero, bottom: .em(0.82))
|
.markdownMargin(top: .zero, bottom: .em(0.82))
|
||||||
}
|
}
|
||||||
.blockquote { configuration in
|
.blockquote { configuration in
|
||||||
|
|||||||
@@ -51,22 +51,19 @@ struct SybilPhoneShellView: View {
|
|||||||
switch nextPhase {
|
switch nextPhase {
|
||||||
case .background:
|
case .background:
|
||||||
shouldRefreshOnForeground = true
|
shouldRefreshOnForeground = true
|
||||||
viewModel.markAppInactiveForNetwork()
|
|
||||||
case .active:
|
case .active:
|
||||||
viewModel.markAppActiveForNetwork()
|
|
||||||
guard shouldRefreshOnForeground else {
|
guard shouldRefreshOnForeground else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
shouldRefreshOnForeground = false
|
shouldRefreshOnForeground = false
|
||||||
Task {
|
Task {
|
||||||
await viewModel.refreshAfterAppBecameActive(
|
await viewModel.refreshVisibleContent(
|
||||||
refreshCollections: path.isEmpty,
|
refreshCollections: path.isEmpty,
|
||||||
refreshSelection: !path.isEmpty && viewModel.hasRefreshableSelection
|
refreshSelection: !path.isEmpty && viewModel.hasRefreshableSelection
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case .inactive:
|
case .inactive:
|
||||||
shouldRefreshOnForeground = true
|
break
|
||||||
viewModel.markAppInactiveForNetwork()
|
|
||||||
@unknown default:
|
@unknown default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -135,6 +132,10 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
}
|
}
|
||||||
.padding(10)
|
.padding(10)
|
||||||
}
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.refreshCollectionsFromUser()
|
||||||
|
}
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(SybilTheme.panelGradient)
|
.background(SybilTheme.panelGradient)
|
||||||
@@ -262,11 +263,7 @@ private struct SybilPhoneDestinationView: View {
|
|||||||
let route: PhoneRoute
|
let route: PhoneRoute
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
SybilWorkspaceView(
|
SybilWorkspaceView(viewModel: viewModel, composerFocusRequest: composerFocusRequest) {
|
||||||
viewModel: viewModel,
|
|
||||||
composerFocusRequest: composerFocusRequest,
|
|
||||||
usesCustomChatNavigation: route.isChatTranscript
|
|
||||||
) {
|
|
||||||
viewModel.startNewChat()
|
viewModel.startNewChat()
|
||||||
composerFocusRequest += 1
|
composerFocusRequest += 1
|
||||||
if path.isEmpty {
|
if path.isEmpty {
|
||||||
@@ -297,14 +294,3 @@ private struct SybilPhoneDestinationView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension PhoneRoute {
|
|
||||||
var isChatTranscript: Bool {
|
|
||||||
switch self {
|
|
||||||
case .chat, .draftChat:
|
|
||||||
return true
|
|
||||||
case .search, .draftSearch, .settings:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,10 +104,6 @@ final class SybilViewModel {
|
|||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private var chatBackgroundTask: SybilBackgroundTaskAssertion?
|
private var chatBackgroundTask: SybilBackgroundTaskAssertion?
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private var isAppActive = true
|
|
||||||
@ObservationIgnored
|
|
||||||
private var appLifecycleGeneration = 0
|
|
||||||
@ObservationIgnored
|
|
||||||
private let clientFactory: (APIConfiguration) -> any SybilAPIClienting
|
private let clientFactory: (APIConfiguration) -> any SybilAPIClienting
|
||||||
|
|
||||||
private let fallbackModels: [Provider: [String]] = [
|
private let fallbackModels: [Provider: [String]] = [
|
||||||
@@ -475,6 +471,34 @@ final class SybilViewModel {
|
|||||||
select(nextSelection)
|
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
|
||||||
@@ -506,38 +530,6 @@ final class SybilViewModel {
|
|||||||
await reconnect()
|
await reconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
func markAppInactiveForNetwork() {
|
|
||||||
guard isAppActive else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isAppActive = false
|
|
||||||
appLifecycleGeneration += 1
|
|
||||||
SybilLog.debug(SybilLog.app, "App became inactive for network lifecycle generation \(appLifecycleGeneration)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func markAppActiveForNetwork() {
|
|
||||||
isAppActive = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshAfterAppBecameActive(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
|
|
||||||
markAppActiveForNetwork()
|
|
||||||
|
|
||||||
guard isAuthenticated, !isCheckingSession else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard shouldRefreshCollections || shouldRefreshSelection else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try? await Task.sleep(for: .milliseconds(150))
|
|
||||||
await refreshVisibleContent(
|
|
||||||
refreshCollections: shouldRefreshCollections,
|
|
||||||
refreshSelection: shouldRefreshSelection
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshVisibleContent(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
|
func refreshVisibleContent(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
|
||||||
guard isAuthenticated, !isCheckingSession else {
|
guard isAuthenticated, !isCheckingSession else {
|
||||||
return
|
return
|
||||||
@@ -746,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
|
||||||
@@ -765,7 +781,6 @@ final class SybilViewModel {
|
|||||||
SybilLog.app,
|
SybilLog.app,
|
||||||
"Refreshed collections: \(nextChats.count) chats, \(nextSearches.count) searches"
|
"Refreshed collections: \(nextChats.count) chats, \(nextSearches.count) searches"
|
||||||
)
|
)
|
||||||
errorMessage = nil
|
|
||||||
|
|
||||||
if case .settings = selectedItem {
|
if case .settings = selectedItem {
|
||||||
isLoadingCollections = false
|
isLoadingCollections = false
|
||||||
@@ -786,12 +801,8 @@ final class SybilViewModel {
|
|||||||
await refreshSelectionIfNeeded()
|
await refreshSelectionIfNeeded()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if shouldSuppressInactiveTransportError(error) {
|
errorMessage = normalizeAPIError(error)
|
||||||
SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive")
|
SybilLog.error(SybilLog.app, "Refresh collections failed", error: error)
|
||||||
} else {
|
|
||||||
errorMessage = normalizeAPIError(error)
|
|
||||||
SybilLog.error(SybilLog.app, "Refresh collections failed", error: error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoadingCollections = false
|
isLoadingCollections = false
|
||||||
@@ -830,12 +841,9 @@ final class SybilViewModel {
|
|||||||
case .settings:
|
case .settings:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
errorMessage = nil
|
|
||||||
} catch {
|
} catch {
|
||||||
if isCancellation(error) {
|
if isCancellation(error) {
|
||||||
SybilLog.debug(SybilLog.app, "Selection refresh cancelled for \(selectedItem.id)")
|
SybilLog.debug(SybilLog.app, "Selection refresh cancelled for \(selectedItem.id)")
|
||||||
} else if shouldSuppressInactiveTransportError(error) {
|
|
||||||
SybilLog.info(SybilLog.app, "Suppressing selection refresh transport interruption while app is inactive")
|
|
||||||
} else {
|
} else {
|
||||||
errorMessage = normalizeAPIError(error)
|
errorMessage = normalizeAPIError(error)
|
||||||
SybilLog.error(SybilLog.app, "Selection refresh failed", error: error)
|
SybilLog.error(SybilLog.app, "Selection refresh failed", error: error)
|
||||||
@@ -926,8 +934,6 @@ final class SybilViewModel {
|
|||||||
} + [CompletionRequestMessage(role: .user, content: content, attachments: attachments.isEmpty ? nil : attachments)]
|
} + [CompletionRequestMessage(role: .user, content: content, attachments: attachments.isEmpty ? nil : attachments)]
|
||||||
|
|
||||||
let streamStatus = CompletionStreamStatus()
|
let streamStatus = CompletionStreamStatus()
|
||||||
let streamLifecycleGeneration = appLifecycleGeneration
|
|
||||||
let streamStartedWhileInactive = !isAppActive
|
|
||||||
|
|
||||||
if isUntitledChat(chatID: chatID, detail: selectedChat) {
|
if isUntitledChat(chatID: chatID, detail: selectedChat) {
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
@@ -963,63 +969,24 @@ final class SybilViewModel {
|
|||||||
chatBackgroundTask = nil
|
chatBackgroundTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
try await client.runCompletionStream(
|
||||||
try await client.runCompletionStream(
|
body: CompletionStreamRequest(
|
||||||
body: CompletionStreamRequest(
|
chatId: chatID,
|
||||||
chatId: chatID,
|
provider: provider,
|
||||||
provider: provider,
|
model: selectedModel,
|
||||||
model: selectedModel,
|
messages: requestMessages
|
||||||
messages: requestMessages
|
)
|
||||||
)
|
) { [weak self] event in
|
||||||
) { [weak self] event in
|
guard let self else { return }
|
||||||
guard let self else { return }
|
await self.applyCompletionEvent(event, streamStatus: streamStatus)
|
||||||
await self.applyCompletionEvent(event, streamStatus: streamStatus)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if shouldSuppressLifecycleTransportError(
|
|
||||||
error,
|
|
||||||
startedAt: streamLifecycleGeneration,
|
|
||||||
startedWhileInactive: streamStartedWhileInactive
|
|
||||||
) {
|
|
||||||
SybilLog.info(SybilLog.app, "Suppressing chat stream transport interruption after app lifecycle change")
|
|
||||||
pendingChatState = nil
|
|
||||||
if isAppActive {
|
|
||||||
await refreshInterruptedStream(preferredSelection: .chat(chatID))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let streamError = await streamStatus.error() {
|
if let streamError = await streamStatus.error() {
|
||||||
throw APIError.httpError(statusCode: 502, message: streamError)
|
throw APIError.httpError(statusCode: 502, message: streamError)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard isAppActive else {
|
|
||||||
pendingChatState = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await refreshCollections(preferredSelection: .chat(chatID))
|
await refreshCollections(preferredSelection: .chat(chatID))
|
||||||
do {
|
selectedChat = try await client.getChat(chatID: chatID)
|
||||||
selectedChat = try await client.getChat(chatID: chatID)
|
|
||||||
} catch {
|
|
||||||
if shouldSuppressLifecycleTransportError(
|
|
||||||
error,
|
|
||||||
startedAt: streamLifecycleGeneration,
|
|
||||||
startedWhileInactive: streamStartedWhileInactive
|
|
||||||
) {
|
|
||||||
SybilLog.info(SybilLog.app, "Suppressing chat refresh transport interruption after app lifecycle change")
|
|
||||||
pendingChatState = nil
|
|
||||||
if isAppActive {
|
|
||||||
await refreshInterruptedStream(preferredSelection: .chat(chatID))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
pendingChatState = nil
|
pendingChatState = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1088,41 +1055,19 @@ final class SybilViewModel {
|
|||||||
)
|
)
|
||||||
|
|
||||||
let streamStatus = SearchStreamStatus()
|
let streamStatus = SearchStreamStatus()
|
||||||
let streamLifecycleGeneration = appLifecycleGeneration
|
|
||||||
let streamStartedWhileInactive = !isAppActive
|
|
||||||
|
|
||||||
do {
|
try await client.runSearchStream(
|
||||||
try await client.runSearchStream(
|
searchID: searchID,
|
||||||
searchID: searchID,
|
body: SearchRunRequest(query: query, title: String(query.prefix(80)), type: "auto", numResults: 10)
|
||||||
body: SearchRunRequest(query: query, title: String(query.prefix(80)), type: "auto", numResults: 10)
|
) { [weak self] event in
|
||||||
) { [weak self] event in
|
guard let self else { return }
|
||||||
guard let self else { return }
|
await self.applySearchEvent(event, searchID: searchID, streamStatus: streamStatus)
|
||||||
await self.applySearchEvent(event, searchID: searchID, streamStatus: streamStatus)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if shouldSuppressLifecycleTransportError(
|
|
||||||
error,
|
|
||||||
startedAt: streamLifecycleGeneration,
|
|
||||||
startedWhileInactive: streamStartedWhileInactive
|
|
||||||
) {
|
|
||||||
SybilLog.info(SybilLog.app, "Suppressing search stream transport interruption after app lifecycle change")
|
|
||||||
if isAppActive {
|
|
||||||
await refreshInterruptedStream(preferredSelection: .search(searchID))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let streamError = await streamStatus.error() {
|
if let streamError = await streamStatus.error() {
|
||||||
throw APIError.httpError(statusCode: 502, message: streamError)
|
throw APIError.httpError(statusCode: 502, message: streamError)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard isAppActive else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await refreshCollections(preferredSelection: .search(searchID))
|
await refreshCollections(preferredSelection: .search(searchID))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1357,57 +1302,6 @@ final class SybilViewModel {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshInterruptedStream(preferredSelection: SidebarSelection) async {
|
|
||||||
try? await Task.sleep(for: .milliseconds(150))
|
|
||||||
await refreshCollections(preferredSelection: preferredSelection)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func shouldSuppressLifecycleTransportError(
|
|
||||||
_ error: Error,
|
|
||||||
startedAt generation: Int,
|
|
||||||
startedWhileInactive: Bool
|
|
||||||
) -> Bool {
|
|
||||||
guard generation != appLifecycleGeneration || startedWhileInactive || !isAppActive else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return isTransientTransportInterruption(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func shouldSuppressInactiveTransportError(_ error: Error) -> Bool {
|
|
||||||
!isAppActive && isTransientTransportInterruption(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isTransientTransportInterruption(_ error: Error) -> Bool {
|
|
||||||
if isCancellation(error) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if let apiError = error as? APIError,
|
|
||||||
case let .networkError(message) = apiError {
|
|
||||||
let lowercased = message.lowercased()
|
|
||||||
return lowercased.contains("network error -999")
|
|
||||||
|| lowercased.contains("network error -1005")
|
|
||||||
|| lowercased.contains("network connection was lost")
|
|
||||||
|| lowercased.contains("software caused connection abort")
|
|
||||||
|| lowercased.contains("socket is not connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
let nsError = error as NSError
|
|
||||||
if nsError.domain == NSURLErrorDomain {
|
|
||||||
return nsError.code == URLError.cancelled.rawValue
|
|
||||||
|| nsError.code == URLError.networkConnectionLost.rawValue
|
|
||||||
|| nsError.code == URLError.notConnectedToInternet.rawValue
|
|
||||||
|| nsError.code == URLError.timedOut.rawValue
|
|
||||||
}
|
|
||||||
|
|
||||||
if nsError.domain == NSPOSIXErrorDomain {
|
|
||||||
return nsError.code == 53 || nsError.code == 57
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isCancellation(_ error: Error) -> Bool {
|
private func isCancellation(_ error: Error) -> Bool {
|
||||||
if error is CancellationError {
|
if error is CancellationError {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import ImageIO
|
|
||||||
import Observation
|
import Observation
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -8,10 +7,8 @@ import UIKit
|
|||||||
struct SybilWorkspaceView: View {
|
struct SybilWorkspaceView: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
var composerFocusRequest: Int = 0
|
var composerFocusRequest: Int = 0
|
||||||
var usesCustomChatNavigation: Bool = false
|
|
||||||
var onRequestNewChat: (() -> Void)? = nil
|
var onRequestNewChat: (() -> Void)? = nil
|
||||||
@FocusState private var composerFocused: Bool
|
@FocusState private var composerFocused: Bool
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
@State private var isShowingAttachmentOptions = false
|
@State private var isShowingAttachmentOptions = false
|
||||||
@State private var isShowingFileImporter = false
|
@State private var isShowingFileImporter = false
|
||||||
@State private var isShowingPhotoPicker = false
|
@State private var isShowingPhotoPicker = false
|
||||||
@@ -26,8 +23,6 @@ struct SybilWorkspaceView: View {
|
|||||||
@State private var newChatSwipeDidTriggerHaptic = false
|
@State private var newChatSwipeDidTriggerHaptic = false
|
||||||
@State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator?
|
@State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator?
|
||||||
|
|
||||||
private let customChatNavigationContentInset: CGFloat = 96
|
|
||||||
|
|
||||||
private var isSettingsSelected: Bool {
|
private var isSettingsSelected: Bool {
|
||||||
if case .settings = viewModel.selectedItem {
|
if case .settings = viewModel.selectedItem {
|
||||||
return true
|
return true
|
||||||
@@ -39,10 +34,6 @@ struct SybilWorkspaceView: View {
|
|||||||
viewModel.errorMessage != nil
|
viewModel.errorMessage != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private var showsCustomChatNavigation: Bool {
|
|
||||||
usesCustomChatNavigation && !isSettingsSelected && !viewModel.isSearchMode
|
|
||||||
}
|
|
||||||
|
|
||||||
private var transcriptScrollContextID: String {
|
private var transcriptScrollContextID: String {
|
||||||
if viewModel.draftKind == .chat {
|
if viewModel.draftKind == .chat {
|
||||||
return "draft-chat"
|
return "draft-chat"
|
||||||
@@ -93,17 +84,16 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
.offset(x: newChatSwipeCompletionOffset)
|
.offset(x: newChatSwipeCompletionOffset)
|
||||||
.background(SybilTheme.background)
|
.background(SybilTheme.background)
|
||||||
.navigationTitle(showsCustomChatNavigation ? "" : viewModel.selectedTitle)
|
.navigationTitle(viewModel.selectedTitle)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbarRole(.editor)
|
.toolbarRole(.editor)
|
||||||
.toolbar(showsCustomChatNavigation ? .hidden : .visible, for: .navigationBar)
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
if !isSettingsSelected && !showsCustomChatNavigation {
|
if !isSettingsSelected {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
if viewModel.isSearchMode {
|
if viewModel.isSearchMode {
|
||||||
searchModeChip
|
searchModeChip
|
||||||
} else {
|
} else {
|
||||||
providerModelToolbarMenu
|
providerModelMenu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,18 +111,6 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var workspaceContent: some View {
|
private var workspaceContent: some View {
|
||||||
ZStack(alignment: .top) {
|
|
||||||
workspaceContentStack
|
|
||||||
|
|
||||||
if showsCustomChatNavigation {
|
|
||||||
SybilChatCharacterBackdrop(isBusy: viewModel.isSending)
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
customChatNavigationBar
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var workspaceContentStack: some View {
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if showsHeader {
|
if showsHeader {
|
||||||
header
|
header
|
||||||
@@ -149,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()
|
||||||
@@ -160,7 +141,9 @@ struct SybilWorkspaceView: View {
|
|||||||
messages: viewModel.displayedMessages,
|
messages: viewModel.displayedMessages,
|
||||||
isLoading: viewModel.isLoadingSelection,
|
isLoading: viewModel.isLoadingSelection,
|
||||||
isSending: viewModel.isSending,
|
isSending: viewModel.isSending,
|
||||||
topContentInset: showsCustomChatNavigation ? customChatNavigationContentInset : 0
|
onRefresh: {
|
||||||
|
await viewModel.refreshSelectionFromUser()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.id(transcriptScrollContextID)
|
.id(transcriptScrollContextID)
|
||||||
}
|
}
|
||||||
@@ -193,35 +176,6 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var customChatNavigationBar: some View {
|
|
||||||
HStack(spacing: 14) {
|
|
||||||
Button {
|
|
||||||
dismiss()
|
|
||||||
} label: {
|
|
||||||
SybilNavigationIcon(systemImage: "chevron.left")
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel("Back")
|
|
||||||
|
|
||||||
Text(viewModel.selectedTitle)
|
|
||||||
.font(.sybil(size: 16, weight: .semibold))
|
|
||||||
.foregroundStyle(SybilTheme.text)
|
|
||||||
.lineLimit(1)
|
|
||||||
.minimumScaleFactor(0.78)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
|
|
||||||
providerModelNavigationMenu
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.top, 10)
|
|
||||||
.padding(.bottom, 34)
|
|
||||||
.background(alignment: .top) {
|
|
||||||
SybilNavigationFadeBackground()
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func beginNewChatSwipe(containerWidth: CGFloat) {
|
private func beginNewChatSwipe(containerWidth: CGFloat) {
|
||||||
let update = {
|
let update = {
|
||||||
newChatSwipeContainerWidth = max(containerWidth, 1)
|
newChatSwipeContainerWidth = max(containerWidth, 1)
|
||||||
@@ -344,8 +298,34 @@ struct SybilWorkspaceView: View {
|
|||||||
.background(SybilTheme.panelGradient.opacity(0.58))
|
.background(SybilTheme.panelGradient.opacity(0.58))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var providerModelToolbarMenu: some View {
|
private var providerModelMenu: some View {
|
||||||
providerModelMenu {
|
Menu {
|
||||||
|
Text("\(viewModel.provider.displayName) • \(viewModel.model)")
|
||||||
|
.font(.sybil(.caption))
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
ForEach(Provider.allCases, id: \.self) { candidate in
|
||||||
|
Menu(candidate.displayName) {
|
||||||
|
let models = viewModel.modelOptions(for: candidate)
|
||||||
|
if models.isEmpty {
|
||||||
|
Text("No models")
|
||||||
|
} else {
|
||||||
|
ForEach(models, id: \.self) { candidateModel in
|
||||||
|
Button {
|
||||||
|
viewModel.setProvider(candidate, model: candidateModel)
|
||||||
|
} label: {
|
||||||
|
if viewModel.provider == candidate && viewModel.model == candidateModel {
|
||||||
|
Label(candidateModel, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(candidateModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
Image(systemName: "ellipsis")
|
Image(systemName: "ellipsis")
|
||||||
.font(.system(size: 18, weight: .semibold))
|
.font(.system(size: 18, weight: .semibold))
|
||||||
.foregroundStyle(SybilTheme.text)
|
.foregroundStyle(SybilTheme.text)
|
||||||
@@ -359,52 +339,9 @@ struct SybilWorkspaceView: View {
|
|||||||
.stroke(SybilTheme.border.opacity(0.82), lineWidth: 1)
|
.stroke(SybilTheme.border.opacity(0.82), lineWidth: 1)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private var providerModelNavigationMenu: some View {
|
|
||||||
providerModelMenu {
|
|
||||||
SybilNavigationIcon(systemImage: "ellipsis")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func providerModelMenu<Label: View>(@ViewBuilder label: @escaping () -> Label) -> some View {
|
|
||||||
Menu {
|
|
||||||
providerModelMenuItems
|
|
||||||
} label: {
|
|
||||||
label()
|
|
||||||
}
|
|
||||||
.accessibilityLabel("Provider and model")
|
.accessibilityLabel("Provider and model")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var providerModelMenuItems: some View {
|
|
||||||
Text("\(viewModel.provider.displayName) • \(viewModel.model)")
|
|
||||||
.font(.sybil(.caption))
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
ForEach(Provider.allCases, id: \.self) { candidate in
|
|
||||||
Menu(candidate.displayName) {
|
|
||||||
let models = viewModel.modelOptions(for: candidate)
|
|
||||||
if models.isEmpty {
|
|
||||||
Text("No models")
|
|
||||||
} else {
|
|
||||||
ForEach(models, id: \.self) { candidateModel in
|
|
||||||
Button {
|
|
||||||
viewModel.setProvider(candidate, model: candidateModel)
|
|
||||||
} label: {
|
|
||||||
if viewModel.provider == candidate && viewModel.model == candidateModel {
|
|
||||||
Label(candidateModel, systemImage: "checkmark")
|
|
||||||
} else {
|
|
||||||
Text(candidateModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var searchModeChip: some View {
|
private var searchModeChip: some View {
|
||||||
Label("Search", systemImage: "globe")
|
Label("Search", systemImage: "globe")
|
||||||
.font(.sybil(.caption, weight: .medium))
|
.font(.sybil(.caption, weight: .medium))
|
||||||
@@ -466,7 +403,9 @@ struct SybilWorkspaceView: View {
|
|||||||
.lineLimit(1 ... 6)
|
.lineLimit(1 ... 6)
|
||||||
.submitLabel(.send)
|
.submitLabel(.send)
|
||||||
.onSubmit {
|
.onSubmit {
|
||||||
submitComposer()
|
Task {
|
||||||
|
await viewModel.sendComposer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
@@ -481,7 +420,9 @@ struct SybilWorkspaceView: View {
|
|||||||
.foregroundStyle(SybilTheme.text)
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
submitComposer()
|
Task {
|
||||||
|
await viewModel.sendComposer()
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up")
|
Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up")
|
||||||
.font(.system(size: 17, weight: .semibold))
|
.font(.system(size: 17, weight: .semibold))
|
||||||
@@ -592,22 +533,6 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func submitComposer() {
|
|
||||||
guard viewModel.canSendComposer else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !targetEnvironment(macCatalyst)
|
|
||||||
if !viewModel.isSearchMode {
|
|
||||||
composerFocused = false
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
Task {
|
|
||||||
await viewModel.sendComposer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func importAttachmentsFromItemProviders(_ providers: [NSItemProvider]) async {
|
private func importAttachmentsFromItemProviders(_ providers: [NSItemProvider]) async {
|
||||||
do {
|
do {
|
||||||
@@ -937,219 +862,6 @@ private extension UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SybilNavigationIcon: View {
|
|
||||||
var systemImage: String
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Image(systemName: systemImage)
|
|
||||||
.font(.system(size: 21, weight: .semibold, design: .rounded))
|
|
||||||
.foregroundStyle(SybilTheme.text)
|
|
||||||
.frame(width: 46, height: 46)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.shadow(color: SybilTheme.primary.opacity(0.34), radius: 12, x: 0, y: 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SybilNavigationFadeBackground: View {
|
|
||||||
var body: some View {
|
|
||||||
ZStack(alignment: .topLeading) {
|
|
||||||
LinearGradient(
|
|
||||||
colors: [
|
|
||||||
SybilTheme.background.opacity(1.0),
|
|
||||||
SybilTheme.background.opacity(0.90),
|
|
||||||
SybilTheme.background.opacity(0.80),
|
|
||||||
SybilTheme.background.opacity(0.80),
|
|
||||||
SybilTheme.background.opacity(0.28),
|
|
||||||
Color.clear
|
|
||||||
],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
|
|
||||||
RadialGradient(
|
|
||||||
colors: [
|
|
||||||
SybilTheme.primary.opacity(0.36),
|
|
||||||
SybilTheme.primary.opacity(0.10),
|
|
||||||
Color.clear
|
|
||||||
],
|
|
||||||
center: .topLeading,
|
|
||||||
startRadius: 6,
|
|
||||||
endRadius: 210
|
|
||||||
)
|
|
||||||
.blendMode(.screen)
|
|
||||||
.offset(x: -44, y: -46)
|
|
||||||
}
|
|
||||||
.ignoresSafeArea(edges: .top)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SybilChatCharacterBackdrop: View {
|
|
||||||
var isBusy: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack(alignment: .topTrailing) {
|
|
||||||
RadialGradient(
|
|
||||||
colors: [
|
|
||||||
SybilTheme.primary.opacity(0.36),
|
|
||||||
SybilTheme.primary.opacity(0.13),
|
|
||||||
Color.clear
|
|
||||||
],
|
|
||||||
center: .center,
|
|
||||||
startRadius: 10,
|
|
||||||
endRadius: 150
|
|
||||||
)
|
|
||||||
.frame(width: 136, height: 118)
|
|
||||||
.blur(radius: 7)
|
|
||||||
.offset(x: 28, y: -24)
|
|
||||||
|
|
||||||
SybilAnimatedGIFView(resourceName: isBusy ? "character-busy" : "character-idle")
|
|
||||||
.frame(width: 172, height: 172)
|
|
||||||
.opacity(0.92)
|
|
||||||
.mask {
|
|
||||||
LinearGradient(
|
|
||||||
colors: [
|
|
||||||
Color.black,
|
|
||||||
Color.black,
|
|
||||||
Color.black.opacity(0)
|
|
||||||
],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.mask {
|
|
||||||
LinearGradient(
|
|
||||||
colors: [
|
|
||||||
Color.clear,
|
|
||||||
Color.black.opacity(0.98),
|
|
||||||
Color.black
|
|
||||||
],
|
|
||||||
startPoint: .leading,
|
|
||||||
endPoint: .trailing
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.offset(x: 8, y: 4)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, minHeight: 156, maxHeight: 156, alignment: .topTrailing)
|
|
||||||
.clipped()
|
|
||||||
.ignoresSafeArea(edges: .top)
|
|
||||||
.accessibilityHidden(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SybilAnimatedGIFView: UIViewRepresentable {
|
|
||||||
var resourceName: String
|
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
|
||||||
Coordinator()
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UIImageView {
|
|
||||||
let imageView = SybilAnimatedUIImageView()
|
|
||||||
imageView.backgroundColor = .clear
|
|
||||||
imageView.contentMode = .scaleAspectFill
|
|
||||||
imageView.clipsToBounds = false
|
|
||||||
imageView.isOpaque = false
|
|
||||||
imageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
||||||
imageView.setContentHuggingPriority(.defaultLow, for: .vertical)
|
|
||||||
imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
||||||
imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
|
||||||
return imageView
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIView(_ imageView: UIImageView, context: Context) {
|
|
||||||
guard context.coordinator.resourceName != resourceName else {
|
|
||||||
if !imageView.isAnimating {
|
|
||||||
imageView.startAnimating()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
context.coordinator.resourceName = resourceName
|
|
||||||
imageView.image = SybilAnimatedGIFCache.image(named: resourceName)
|
|
||||||
imageView.startAnimating()
|
|
||||||
}
|
|
||||||
|
|
||||||
final class Coordinator {
|
|
||||||
var resourceName: String?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class SybilAnimatedUIImageView: UIImageView {
|
|
||||||
override var intrinsicContentSize: CGSize {
|
|
||||||
CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private enum SybilAnimatedGIFCache {
|
|
||||||
private static var images: [String: UIImage] = [:]
|
|
||||||
|
|
||||||
static func image(named name: String) -> UIImage? {
|
|
||||||
if let cached = images[name] {
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let image = loadImage(named: name) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
images[name] = image
|
|
||||||
|
|
||||||
return image
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func loadImage(named name: String) -> UIImage? {
|
|
||||||
let url = Bundle.main.url(forResource: name, withExtension: "gif", subdirectory: "Character") ??
|
|
||||||
Bundle.main.url(forResource: name, withExtension: "gif")
|
|
||||||
|
|
||||||
guard let url,
|
|
||||||
let data = try? Data(contentsOf: url),
|
|
||||||
let source = CGImageSourceCreateWithData(data as CFData, nil)
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let frameCount = CGImageSourceGetCount(source)
|
|
||||||
guard frameCount > 0 else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var frames: [UIImage] = []
|
|
||||||
frames.reserveCapacity(frameCount)
|
|
||||||
var duration: TimeInterval = 0
|
|
||||||
|
|
||||||
for index in 0 ..< frameCount {
|
|
||||||
guard let cgImage = CGImageSourceCreateImageAtIndex(source, index, nil) else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
frames.append(UIImage(cgImage: cgImage))
|
|
||||||
duration += frameDuration(at: index, source: source)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard !frames.isEmpty else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if frames.count == 1 {
|
|
||||||
return frames[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
return UIImage.animatedImage(with: frames, duration: max(duration, 0.1))
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func frameDuration(at index: Int, source: CGImageSource) -> TimeInterval {
|
|
||||||
guard let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any],
|
|
||||||
let gifProperties = properties[kCGImagePropertyGIFDictionary] as? [CFString: Any]
|
|
||||||
else {
|
|
||||||
return 0.08
|
|
||||||
}
|
|
||||||
|
|
||||||
let unclampedDelay = gifProperties[kCGImagePropertyGIFUnclampedDelayTime] as? TimeInterval
|
|
||||||
let delay = unclampedDelay ?? gifProperties[kCGImagePropertyGIFDelayTime] as? TimeInterval ?? 0.08
|
|
||||||
return max(delay, 0.03)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct NewChatSwipeBackdrop: View {
|
private struct NewChatSwipeBackdrop: View {
|
||||||
var progress: CGFloat
|
var progress: CGFloat
|
||||||
var hasLatched: Bool
|
var hasLatched: Bool
|
||||||
|
|||||||
@@ -19,10 +19,6 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
private let searchDetails: [String: SearchDetail]
|
private let searchDetails: [String: SearchDetail]
|
||||||
|
|
||||||
private var snapshot = MockClientCallSnapshot()
|
private var snapshot = MockClientCallSnapshot()
|
||||||
private var completionStreamNetworkErrorMessage: String?
|
|
||||||
private var completionStreamDelayNanoseconds: UInt64 = 0
|
|
||||||
private var searchStreamNetworkErrorMessage: String?
|
|
||||||
private var searchStreamDelayNanoseconds: UInt64 = 0
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
chatsResponse: [ChatSummary] = [],
|
chatsResponse: [ChatSummary] = [],
|
||||||
@@ -40,16 +36,6 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
snapshot
|
snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
|
|
||||||
completionStreamNetworkErrorMessage = message
|
|
||||||
completionStreamDelayNanoseconds = delayNanoseconds
|
|
||||||
}
|
|
||||||
|
|
||||||
func setSearchStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
|
|
||||||
searchStreamNetworkErrorMessage = message
|
|
||||||
searchStreamDelayNanoseconds = delayNanoseconds
|
|
||||||
}
|
|
||||||
|
|
||||||
func verifySession() async throws -> AuthSession {
|
func verifySession() async throws -> AuthSession {
|
||||||
AuthSession(authenticated: true, mode: "open")
|
AuthSession(authenticated: true, mode: "open")
|
||||||
}
|
}
|
||||||
@@ -112,12 +98,6 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
body: CompletionStreamRequest,
|
body: CompletionStreamRequest,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
) async throws {
|
) async throws {
|
||||||
if completionStreamDelayNanoseconds > 0 {
|
|
||||||
try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
|
|
||||||
}
|
|
||||||
if let completionStreamNetworkErrorMessage {
|
|
||||||
throw APIError.networkError(message: completionStreamNetworkErrorMessage)
|
|
||||||
}
|
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,12 +106,6 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
body: SearchRunRequest,
|
body: SearchRunRequest,
|
||||||
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||||
) async throws {
|
) async throws {
|
||||||
if searchStreamDelayNanoseconds > 0 {
|
|
||||||
try await Task.sleep(nanoseconds: searchStreamDelayNanoseconds)
|
|
||||||
}
|
|
||||||
if let searchStreamNetworkErrorMessage {
|
|
||||||
throw APIError.networkError(message: searchStreamNetworkErrorMessage)
|
|
||||||
}
|
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,84 +267,6 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
#expect(viewModel.selectedSearch?.answerText == "fresh answer")
|
#expect(viewModel.selectedSearch?.answerText == "fresh answer")
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func backgroundChatStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws {
|
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_300)
|
|
||||||
let chat = makeChatSummary(id: "chat-3", date: date)
|
|
||||||
let initialDetail = makeChatDetail(id: "chat-3", date: date, body: "stale transcript")
|
|
||||||
let refreshedDetail = makeChatDetail(id: "chat-3", date: date, body: "fresh transcript")
|
|
||||||
let client = MockSybilClient(
|
|
||||||
chatsResponse: [chat],
|
|
||||||
chatDetails: ["chat-3": refreshedDetail]
|
|
||||||
)
|
|
||||||
await client.setCompletionStreamNetworkError(
|
|
||||||
"Network error -1005 while requesting POST: The network connection was lost.",
|
|
||||||
delayNanoseconds: 50_000_000
|
|
||||||
)
|
|
||||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
|
||||||
viewModel.isAuthenticated = true
|
|
||||||
viewModel.isCheckingSession = false
|
|
||||||
viewModel.selectedItem = .chat("chat-3")
|
|
||||||
viewModel.selectedChat = initialDetail
|
|
||||||
viewModel.composer = "continue"
|
|
||||||
|
|
||||||
let sendTask = Task {
|
|
||||||
await viewModel.sendComposer()
|
|
||||||
}
|
|
||||||
try await Task.sleep(nanoseconds: 10_000_000)
|
|
||||||
viewModel.markAppInactiveForNetwork()
|
|
||||||
await sendTask.value
|
|
||||||
|
|
||||||
#expect(viewModel.errorMessage == nil)
|
|
||||||
#expect(viewModel.composer.isEmpty)
|
|
||||||
#expect(!viewModel.isSending)
|
|
||||||
#expect(viewModel.selectedChat?.messages.first?.content == "stale transcript")
|
|
||||||
|
|
||||||
await viewModel.refreshAfterAppBecameActive(refreshCollections: false, refreshSelection: true)
|
|
||||||
|
|
||||||
let snapshot = await client.currentSnapshot()
|
|
||||||
#expect(snapshot.getChat == 1)
|
|
||||||
#expect(viewModel.errorMessage == nil)
|
|
||||||
#expect(viewModel.selectedChat?.messages.first?.content == "fresh transcript")
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func backgroundSearchStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws {
|
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_400)
|
|
||||||
let refreshedDetail = makeSearchDetail(id: "search-3", date: date, answer: "fresh answer")
|
|
||||||
let client = MockSybilClient(
|
|
||||||
searchDetails: ["search-3": refreshedDetail]
|
|
||||||
)
|
|
||||||
await client.setSearchStreamNetworkError(
|
|
||||||
"Network error -1005 while requesting POST: The network connection was lost.",
|
|
||||||
delayNanoseconds: 50_000_000
|
|
||||||
)
|
|
||||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
|
||||||
viewModel.isAuthenticated = true
|
|
||||||
viewModel.isCheckingSession = false
|
|
||||||
viewModel.selectedItem = .search("search-3")
|
|
||||||
viewModel.selectedSearch = makeSearchDetail(id: "search-3", date: date, answer: "stale answer")
|
|
||||||
viewModel.composer = "refresh me"
|
|
||||||
|
|
||||||
let sendTask = Task {
|
|
||||||
await viewModel.sendComposer()
|
|
||||||
}
|
|
||||||
try await Task.sleep(nanoseconds: 10_000_000)
|
|
||||||
viewModel.markAppInactiveForNetwork()
|
|
||||||
await sendTask.value
|
|
||||||
|
|
||||||
#expect(viewModel.errorMessage == nil)
|
|
||||||
#expect(viewModel.composer.isEmpty)
|
|
||||||
#expect(!viewModel.isSending)
|
|
||||||
|
|
||||||
await viewModel.refreshAfterAppBecameActive(refreshCollections: false, refreshSelection: true)
|
|
||||||
|
|
||||||
let snapshot = await client.currentSnapshot()
|
|
||||||
#expect(snapshot.getSearch == 1)
|
|
||||||
#expect(viewModel.errorMessage == nil)
|
|
||||||
#expect(viewModel.selectedSearch?.answerText == "fresh answer")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func newChatSwipeMetricsClampProgressAndLatch() async throws {
|
@Test func newChatSwipeMetricsClampProgressAndLatch() async throws {
|
||||||
let width: CGFloat = 390
|
let width: CGFloat = 390
|
||||||
let maxTravel = NewChatSwipeMetrics.maxTravel(for: width)
|
let maxTravel = NewChatSwipeMetrics.maxTravel(for: width)
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB |
@@ -7,7 +7,6 @@ import { AuthScreen } from "@/components/auth/auth-screen";
|
|||||||
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
||||||
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
|
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
|
||||||
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
||||||
import { SybilCharacter } from "@/components/sybil-character";
|
|
||||||
import {
|
import {
|
||||||
createChat,
|
createChat,
|
||||||
createChatFromSearch,
|
createChatFromSearch,
|
||||||
@@ -1990,20 +1989,14 @@ export default function App() {
|
|||||||
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="relative min-h-24 px-4 pb-4 pt-5">
|
<div className="px-4 pb-4 pt-5">
|
||||||
<div className="pr-24">
|
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
|
||||||
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
|
SYBIL
|
||||||
SYBIL
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
|
||||||
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<SybilCharacter
|
<p className="mt-2 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||||
className="absolute right-4 top-3 h-[calc(100%-1.5rem)]"
|
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||||
isBusy={isSending}
|
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
|
||||||
/>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 px-3 pb-3">
|
<div className="space-y-3 px-3 pb-3">
|
||||||
@@ -2253,7 +2246,7 @@ export default function App() {
|
|||||||
void handleSend();
|
void handleSend();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={isSearchMode ? "Search the web" : "Enter prompt..."}
|
placeholder={isSearchMode ? "Search the web" : "Message Sybil..."}
|
||||||
className="max-h-40 min-h-0 resize-none overflow-y-auto border-0 bg-transparent px-3 py-3 text-base text-violet-50 shadow-none placeholder:text-violet-200/45 focus-visible:ring-0"
|
className="max-h-40 min-h-0 resize-none overflow-y-auto border-0 bg-transparent px-3 py-3 text-base text-violet-50 shadow-none placeholder:text-violet-200/45 focus-visible:ring-0"
|
||||||
disabled={isSending}
|
disabled={isSending}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import { useEffect } from "preact/hooks";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const CHARACTER_IDLE_SRC = "/character-idle.gif";
|
|
||||||
const CHARACTER_BUSY_SRC = "/character-busy.gif";
|
|
||||||
|
|
||||||
type SybilCharacterProps = {
|
|
||||||
className?: string;
|
|
||||||
isBusy?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SybilCharacter({ className, isBusy = false }: SybilCharacterProps) {
|
|
||||||
useEffect(() => {
|
|
||||||
const busyImage = new Image();
|
|
||||||
busyImage.src = CHARACTER_BUSY_SRC;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
aria-hidden="true"
|
|
||||||
alt=""
|
|
||||||
className={cn(
|
|
||||||
"aspect-square rounded-xl border border-violet-200/24 bg-white/6 object-cover p-1 shadow-[inset_0_1px_0_hsl(252_90%_86%/0.12),0_10px_24px_hsl(240_80%_2%/0.3)]",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
data-state={isBusy ? "busy" : "idle"}
|
|
||||||
draggable={false}
|
|
||||||
src={isBusy ? CHARACTER_BUSY_SRC : CHARACTER_IDLE_SRC}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -184,13 +184,7 @@ textarea {
|
|||||||
margin-top: 0.65rem;
|
margin-top: 0.65rem;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
list-style: none;
|
list-style-position: inside;
|
||||||
}
|
|
||||||
|
|
||||||
.md-content li > ul,
|
|
||||||
.md-content li > ol {
|
|
||||||
margin-top: 0.3rem;
|
|
||||||
padding-left: 1.35rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content li + li {
|
.md-content li + li {
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/app.tsx","./src/main.tsx","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
{"root":["./src/app.tsx","./src/main.tsx","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user