7 Commits

15 changed files with 697 additions and 74 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -72,19 +72,22 @@ public struct SplitView: View {
switch nextPhase {
case .background:
shouldRefreshOnForeground = true
viewModel.markAppInactiveForNetwork()
case .active:
viewModel.markAppActiveForNetwork()
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
return
}
shouldRefreshOnForeground = false
Task {
await viewModel.refreshVisibleContent(
await viewModel.refreshAfterAppBecameActive(
refreshCollections: true,
refreshSelection: viewModel.hasRefreshableSelection
)
}
case .inactive:
break
shouldRefreshOnForeground = true
viewModel.markAppInactiveForNetwork()
@unknown default:
break
}

View File

@@ -5,6 +5,7 @@ struct SybilChatTranscriptView: View {
var messages: [Message]
var isLoading: Bool
var isSending: Bool
var topContentInset: CGFloat = 0
@State private var hasHandledInitialTranscriptScroll = false
private var hasPendingAssistant: Bool {
@@ -48,7 +49,8 @@ struct SybilChatTranscriptView: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.vertical, 18)
.padding(.top, 18 + topContentInset)
.padding(.bottom, 18)
}
.frame(maxWidth: .infinity, alignment: .leading)
.scrollDismissesKeyboard(.interactively)

View File

@@ -85,7 +85,7 @@ extension Theme {
.paragraph { configuration in
configuration.label
.fixedSize(horizontal: false, vertical: true)
.relativeLineSpacing(.em(0.36))
.relativeLineSpacing(.em(0.46))
.markdownMargin(top: .zero, bottom: .em(0.82))
}
.blockquote { configuration in

View File

@@ -51,19 +51,22 @@ struct SybilPhoneShellView: View {
switch nextPhase {
case .background:
shouldRefreshOnForeground = true
viewModel.markAppInactiveForNetwork()
case .active:
viewModel.markAppActiveForNetwork()
guard shouldRefreshOnForeground else {
return
}
shouldRefreshOnForeground = false
Task {
await viewModel.refreshVisibleContent(
await viewModel.refreshAfterAppBecameActive(
refreshCollections: path.isEmpty,
refreshSelection: !path.isEmpty && viewModel.hasRefreshableSelection
)
}
case .inactive:
break
shouldRefreshOnForeground = true
viewModel.markAppInactiveForNetwork()
@unknown default:
break
}
@@ -259,7 +262,11 @@ private struct SybilPhoneDestinationView: View {
let route: PhoneRoute
var body: some View {
SybilWorkspaceView(viewModel: viewModel, composerFocusRequest: composerFocusRequest) {
SybilWorkspaceView(
viewModel: viewModel,
composerFocusRequest: composerFocusRequest,
usesCustomChatNavigation: route.isChatTranscript
) {
viewModel.startNewChat()
composerFocusRequest += 1
if path.isEmpty {
@@ -290,3 +297,14 @@ private struct SybilPhoneDestinationView: View {
}
}
}
private extension PhoneRoute {
var isChatTranscript: Bool {
switch self {
case .chat, .draftChat:
return true
case .search, .draftSearch, .settings:
return false
}
}
}

View File

@@ -104,6 +104,10 @@ final class SybilViewModel {
@ObservationIgnored
private var chatBackgroundTask: SybilBackgroundTaskAssertion?
@ObservationIgnored
private var isAppActive = true
@ObservationIgnored
private var appLifecycleGeneration = 0
@ObservationIgnored
private let clientFactory: (APIConfiguration) -> any SybilAPIClienting
private let fallbackModels: [Provider: [String]] = [
@@ -502,6 +506,38 @@ final class SybilViewModel {
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 {
guard isAuthenticated, !isCheckingSession else {
return
@@ -729,6 +765,7 @@ final class SybilViewModel {
SybilLog.app,
"Refreshed collections: \(nextChats.count) chats, \(nextSearches.count) searches"
)
errorMessage = nil
if case .settings = selectedItem {
isLoadingCollections = false
@@ -749,8 +786,12 @@ final class SybilViewModel {
await refreshSelectionIfNeeded()
}
} catch {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.app, "Refresh collections failed", error: error)
if shouldSuppressInactiveTransportError(error) {
SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive")
} else {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.app, "Refresh collections failed", error: error)
}
}
isLoadingCollections = false
@@ -789,9 +830,12 @@ final class SybilViewModel {
case .settings:
break
}
errorMessage = nil
} catch {
if isCancellation(error) {
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 {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.app, "Selection refresh failed", error: error)
@@ -882,6 +926,8 @@ final class SybilViewModel {
} + [CompletionRequestMessage(role: .user, content: content, attachments: attachments.isEmpty ? nil : attachments)]
let streamStatus = CompletionStreamStatus()
let streamLifecycleGeneration = appLifecycleGeneration
let streamStartedWhileInactive = !isAppActive
if isUntitledChat(chatID: chatID, detail: selectedChat) {
Task { [weak self] in
@@ -917,24 +963,63 @@ final class SybilViewModel {
chatBackgroundTask = nil
}
try await client.runCompletionStream(
body: CompletionStreamRequest(
chatId: chatID,
provider: provider,
model: selectedModel,
messages: requestMessages
)
) { [weak self] event in
guard let self else { return }
await self.applyCompletionEvent(event, streamStatus: streamStatus)
do {
try await client.runCompletionStream(
body: CompletionStreamRequest(
chatId: chatID,
provider: provider,
model: selectedModel,
messages: requestMessages
)
) { [weak self] event in
guard let self else { return }
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() {
throw APIError.httpError(statusCode: 502, message: streamError)
}
guard isAppActive else {
pendingChatState = nil
return
}
await refreshCollections(preferredSelection: .chat(chatID))
selectedChat = try await client.getChat(chatID: chatID)
do {
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
}
@@ -1003,19 +1088,41 @@ final class SybilViewModel {
)
let streamStatus = SearchStreamStatus()
let streamLifecycleGeneration = appLifecycleGeneration
let streamStartedWhileInactive = !isAppActive
try await client.runSearchStream(
searchID: searchID,
body: SearchRunRequest(query: query, title: String(query.prefix(80)), type: "auto", numResults: 10)
) { [weak self] event in
guard let self else { return }
await self.applySearchEvent(event, searchID: searchID, streamStatus: streamStatus)
do {
try await client.runSearchStream(
searchID: searchID,
body: SearchRunRequest(query: query, title: String(query.prefix(80)), type: "auto", numResults: 10)
) { [weak self] event in
guard let self else { return }
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() {
throw APIError.httpError(statusCode: 502, message: streamError)
}
guard isAppActive else {
return
}
await refreshCollections(preferredSelection: .search(searchID))
}
@@ -1250,6 +1357,57 @@ final class SybilViewModel {
#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 {
if error is CancellationError {
return true

View File

@@ -1,3 +1,4 @@
import ImageIO
import Observation
import PhotosUI
import SwiftUI
@@ -7,8 +8,10 @@ import UIKit
struct SybilWorkspaceView: View {
@Bindable var viewModel: SybilViewModel
var composerFocusRequest: Int = 0
var usesCustomChatNavigation: Bool = false
var onRequestNewChat: (() -> Void)? = nil
@FocusState private var composerFocused: Bool
@Environment(\.dismiss) private var dismiss
@State private var isShowingAttachmentOptions = false
@State private var isShowingFileImporter = false
@State private var isShowingPhotoPicker = false
@@ -23,6 +26,8 @@ struct SybilWorkspaceView: View {
@State private var newChatSwipeDidTriggerHaptic = false
@State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator?
private let customChatNavigationContentInset: CGFloat = 96
private var isSettingsSelected: Bool {
if case .settings = viewModel.selectedItem {
return true
@@ -34,6 +39,10 @@ struct SybilWorkspaceView: View {
viewModel.errorMessage != nil
}
private var showsCustomChatNavigation: Bool {
usesCustomChatNavigation && !isSettingsSelected && !viewModel.isSearchMode
}
private var transcriptScrollContextID: String {
if viewModel.draftKind == .chat {
return "draft-chat"
@@ -84,16 +93,17 @@ struct SybilWorkspaceView: View {
}
.offset(x: newChatSwipeCompletionOffset)
.background(SybilTheme.background)
.navigationTitle(viewModel.selectedTitle)
.navigationTitle(showsCustomChatNavigation ? "" : viewModel.selectedTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbarRole(.editor)
.toolbar(showsCustomChatNavigation ? .hidden : .visible, for: .navigationBar)
.toolbar {
if !isSettingsSelected {
if !isSettingsSelected && !showsCustomChatNavigation {
ToolbarItem(placement: .topBarTrailing) {
if viewModel.isSearchMode {
searchModeChip
} else {
providerModelMenu
providerModelToolbarMenu
}
}
}
@@ -111,6 +121,18 @@ struct SybilWorkspaceView: 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) {
if showsHeader {
header
@@ -137,7 +159,8 @@ struct SybilWorkspaceView: View {
SybilChatTranscriptView(
messages: viewModel.displayedMessages,
isLoading: viewModel.isLoadingSelection,
isSending: viewModel.isSending
isSending: viewModel.isSending,
topContentInset: showsCustomChatNavigation ? customChatNavigationContentInset : 0
)
.id(transcriptScrollContextID)
}
@@ -170,6 +193,35 @@ 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) {
let update = {
newChatSwipeContainerWidth = max(containerWidth, 1)
@@ -292,34 +344,8 @@ struct SybilWorkspaceView: View {
.background(SybilTheme.panelGradient.opacity(0.58))
}
private var providerModelMenu: some View {
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: {
private var providerModelToolbarMenu: some View {
providerModelMenu {
Image(systemName: "ellipsis")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(SybilTheme.text)
@@ -333,9 +359,52 @@ struct SybilWorkspaceView: View {
.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")
}
@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 {
Label("Search", systemImage: "globe")
.font(.sybil(.caption, weight: .medium))
@@ -397,9 +466,7 @@ struct SybilWorkspaceView: View {
.lineLimit(1 ... 6)
.submitLabel(.send)
.onSubmit {
Task {
await viewModel.sendComposer()
}
submitComposer()
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
@@ -414,9 +481,7 @@ struct SybilWorkspaceView: View {
.foregroundStyle(SybilTheme.text)
Button {
Task {
await viewModel.sendComposer()
}
submitComposer()
} label: {
Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up")
.font(.system(size: 17, weight: .semibold))
@@ -527,6 +592,22 @@ 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
private func importAttachmentsFromItemProviders(_ providers: [NSItemProvider]) async {
do {
@@ -856,6 +937,219 @@ 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.74),
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 {
var progress: CGFloat
var hasLatched: Bool

View File

@@ -19,6 +19,10 @@ private actor MockSybilClient: SybilAPIClienting {
private let searchDetails: [String: SearchDetail]
private var snapshot = MockClientCallSnapshot()
private var completionStreamNetworkErrorMessage: String?
private var completionStreamDelayNanoseconds: UInt64 = 0
private var searchStreamNetworkErrorMessage: String?
private var searchStreamDelayNanoseconds: UInt64 = 0
init(
chatsResponse: [ChatSummary] = [],
@@ -36,6 +40,16 @@ private actor MockSybilClient: SybilAPIClienting {
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 {
AuthSession(authenticated: true, mode: "open")
}
@@ -98,6 +112,12 @@ private actor MockSybilClient: SybilAPIClienting {
body: CompletionStreamRequest,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
) async throws {
if completionStreamDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
}
if let completionStreamNetworkErrorMessage {
throw APIError.networkError(message: completionStreamNetworkErrorMessage)
}
throw UnexpectedClientCall()
}
@@ -106,6 +126,12 @@ private actor MockSybilClient: SybilAPIClienting {
body: SearchRunRequest,
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
) async throws {
if searchStreamDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: searchStreamDelayNanoseconds)
}
if let searchStreamNetworkErrorMessage {
throw APIError.networkError(message: searchStreamNetworkErrorMessage)
}
throw UnexpectedClientCall()
}
}
@@ -267,6 +293,84 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
#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 {
let width: CGFloat = 390
let maxTravel = NewChatSwipeMetrics.maxTravel(for: width)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -7,6 +7,7 @@ import { AuthScreen } from "@/components/auth/auth-screen";
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
import { SearchResultsPanel } from "@/components/search/search-results-panel";
import { SybilCharacter } from "@/components/sybil-character";
import {
createChat,
createChatFromSearch,
@@ -1989,14 +1990,20 @@ export default function App() {
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
)}
>
<div className="px-4 pb-4 pt-5">
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
SYBIL
<div className="relative min-h-24 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">
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>
<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>
<SybilCharacter
className="absolute right-4 top-3 h-[calc(100%-1.5rem)]"
isBusy={isSending}
/>
</div>
<div className="space-y-3 px-3 pb-3">
@@ -2246,7 +2253,7 @@ export default function App() {
void handleSend();
}
}}
placeholder={isSearchMode ? "Search the web" : "Message Sybil..."}
placeholder={isSearchMode ? "Search the web" : "Enter prompt..."}
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}
/>

View File

@@ -0,0 +1,31 @@
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}
/>
);
}

View File

@@ -184,7 +184,13 @@ textarea {
margin-top: 0.65rem;
margin-left: 0;
padding-left: 0;
list-style-position: inside;
list-style: none;
}
.md-content li > ul,
.md-content li > ol {
margin-top: 0.3rem;
padding-left: 1.35rem;
}
.md-content li + li {

View File

@@ -1 +1 @@
{"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"}
{"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"}