Compare commits
1 Commits
70a60edf1c
...
ios-pull-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9572d0320f |
@@ -8,19 +8,8 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
|
|||||||
- `just build` will:
|
- `just build` will:
|
||||||
1. generate `Sybil.xcodeproj` with `xcodegen` if missing,
|
1. generate `Sybil.xcodeproj` with `xcodegen` if missing,
|
||||||
2. build scheme `Sybil` for `iPhone 16e` simulator.
|
2. build scheme `Sybil` for `iPhone 16e` simulator.
|
||||||
- Preferred test command: `just test`
|
|
||||||
- `just test` runs the Swift package tests through `xcodebuild test` on the `iPhone 16e` iOS simulator from `ios/Packages/Sybil`.
|
|
||||||
- `just test` disables Xcode parallel testing because the current async view-model tests use timing-sensitive selection tasks.
|
|
||||||
- Do not use plain `swift test` for this package; it runs as host macOS and hits a deployment mismatch with `MarkdownUI`.
|
|
||||||
- If `xcbeautify` is installed it is used automatically; otherwise raw `xcodebuild` output is used.
|
- If `xcbeautify` is installed it is used automatically; otherwise raw `xcodebuild` output is used.
|
||||||
|
|
||||||
## Simulator Workflow
|
|
||||||
- Run the app in the simulator with `just run` from `/Users/buzzert/src/sybil-2/ios`.
|
|
||||||
- `just run` boots the `iPhone 16e` simulator if needed, builds with a stable derived data path, installs `Sybil.app`, and launches bundle id `net.buzzert.sybil2`.
|
|
||||||
- Capture a simulator screenshot with `just screenshot` from `/Users/buzzert/src/sybil-2/ios`; it writes `build/sybil-screenshot.png` by default.
|
|
||||||
- To choose a screenshot path, run `just screenshot path=build/name.png`.
|
|
||||||
- The underlying screenshot command is `xcrun simctl io booted screenshot <path>` and requires a booted simulator.
|
|
||||||
|
|
||||||
## App Structure
|
## App Structure
|
||||||
- App target entry: `/Users/buzzert/src/sybil-2/ios/Apps/Sybil/Sources/SybilApp.swift`
|
- App target entry: `/Users/buzzert/src/sybil-2/ios/Apps/Sybil/Sources/SybilApp.swift`
|
||||||
- Shared iOS app code lives in Swift package:
|
- Shared iOS app code lives in Swift package:
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
@@ -23,8 +23,8 @@ targets:
|
|||||||
TARGETED_DEVICE_FAMILY: "1,2,6"
|
TARGETED_DEVICE_FAMILY: "1,2,6"
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
MARKETING_VERSION: 1.5
|
MARKETING_VERSION: 1.4
|
||||||
CURRENT_PROJECT_VERSION: 6
|
CURRENT_PROJECT_VERSION: 5
|
||||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ public struct SplitView: View {
|
|||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var shouldRefreshOnForeground = false
|
@State private var shouldRefreshOnForeground = false
|
||||||
@State private var composerFocusRequest = 0
|
@State private var composerFocusRequest = 0
|
||||||
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
|
|
||||||
|
|
||||||
private var keyboardActions: SybilKeyboardActions? {
|
private var keyboardActions: SybilKeyboardActions? {
|
||||||
guard !viewModel.isCheckingSession, viewModel.isAuthenticated else {
|
guard !viewModel.isCheckingSession, viewModel.isAuthenticated else {
|
||||||
@@ -51,26 +50,18 @@ public struct SplitView: View {
|
|||||||
} else if horizontalSizeClass == .compact {
|
} else if horizontalSizeClass == .compact {
|
||||||
SybilPhoneShellView(viewModel: viewModel)
|
SybilPhoneShellView(viewModel: viewModel)
|
||||||
} else {
|
} else {
|
||||||
GeometryReader { proxy in
|
NavigationSplitView {
|
||||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
|
||||||
SybilSidebarView(viewModel: viewModel)
|
SybilSidebarView(viewModel: viewModel)
|
||||||
} detail: {
|
} detail: {
|
||||||
SybilWorkspaceView(
|
SybilWorkspaceView(viewModel: viewModel, composerFocusRequest: composerFocusRequest) {
|
||||||
viewModel: viewModel,
|
|
||||||
composerFocusRequest: composerFocusRequest,
|
|
||||||
navigationLeadingControl: splitNavigationLeadingControl(for: proxy.size),
|
|
||||||
onShowSidebar: showSidebar,
|
|
||||||
onRequestNewChat: {
|
|
||||||
viewModel.startNewChat()
|
viewModel.startNewChat()
|
||||||
composerFocusRequest += 1
|
composerFocusRequest += 1
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.navigationSplitViewStyle(.balanced)
|
.navigationSplitViewStyle(.balanced)
|
||||||
.tint(SybilTheme.primary)
|
.tint(SybilTheme.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.font(.sybil(.body))
|
.font(.sybil(.body))
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
||||||
@@ -81,37 +72,24 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func splitNavigationLeadingControl(for size: CGSize) -> SybilWorkspaceNavigationLeadingControl {
|
|
||||||
return size.width < size.height ? .showSidebar : .hidden
|
|
||||||
}
|
|
||||||
|
|
||||||
private func showSidebar() {
|
|
||||||
withAnimation(.easeInOut(duration: 0.22)) {
|
|
||||||
columnVisibility = .all
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SybilCommands: Commands {
|
public struct SybilCommands: Commands {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ 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
|
||||||
var bottomContentInset: CGFloat = 0
|
@State private var hasHandledInitialTranscriptScroll = false
|
||||||
|
|
||||||
private var hasPendingAssistant: Bool {
|
private var hasPendingAssistant: Bool {
|
||||||
messages.contains { message in
|
messages.contains { message in
|
||||||
@@ -15,8 +15,22 @@ struct SybilChatTranscriptView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 26) {
|
LazyVStack(alignment: .leading, spacing: 26) {
|
||||||
|
if isLoading && messages.isEmpty {
|
||||||
|
Text("Loading messages…")
|
||||||
|
.font(.sybil(.footnote))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
.padding(.top, 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(messages) { message in
|
||||||
|
MessageBubble(message: message, isSending: isSending)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.id(message.id)
|
||||||
|
}
|
||||||
|
|
||||||
if isSending && !hasPendingAssistant {
|
if isSending && !hasPendingAssistant {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -26,31 +40,44 @@ struct SybilChatTranscriptView: View {
|
|||||||
.font(.sybil(.footnote))
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
}
|
}
|
||||||
.scaleEffect(x: 1, y: -1)
|
.id("typing-indicator")
|
||||||
}
|
}
|
||||||
|
|
||||||
ForEach(messages.reversed()) { message in
|
Color.clear
|
||||||
MessageBubble(message: message, isSending: isSending)
|
.frame(height: 2)
|
||||||
.frame(maxWidth: .infinity)
|
.id("chat-bottom-anchor")
|
||||||
.scaleEffect(x: 1, y: -1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isLoading && messages.isEmpty {
|
|
||||||
Text("Loading messages…")
|
|
||||||
.font(.sybil(.footnote))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
.padding(.top, 24)
|
|
||||||
.scaleEffect(x: 1, y: -1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.top, 18 + bottomContentInset)
|
.padding(.vertical, 18)
|
||||||
.padding(.bottom, 18 + topContentInset)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.refreshable {
|
||||||
|
await onRefresh?()
|
||||||
|
}
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.scaleEffect(x: 1, y: -1)
|
.onAppear {
|
||||||
|
scrollToBottom(with: proxy, animated: false)
|
||||||
|
}
|
||||||
|
.onChange(of: messages.map(\.id)) { _, _ in
|
||||||
|
scrollToBottom(with: proxy, animated: hasHandledInitialTranscriptScroll && !isLoading)
|
||||||
|
hasHandledInitialTranscriptScroll = true
|
||||||
|
}
|
||||||
|
.onChange(of: isSending) { _, _ in
|
||||||
|
scrollToBottom(with: proxy, animated: hasHandledInitialTranscriptScroll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scrollToBottom(with proxy: ScrollViewProxy, animated: Bool) {
|
||||||
|
if animated {
|
||||||
|
withAnimation(.easeOut(duration: 0.22)) {
|
||||||
|
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +137,6 @@ private struct MessageBubble: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, isUser ? 14 : 2)
|
.padding(.horizontal, isUser ? 14 : 2)
|
||||||
.padding(.vertical, isUser ? 13 : 2)
|
.padding(.vertical, isUser ? 13 : 2)
|
||||||
.textSelection(.enabled)
|
|
||||||
.background(
|
.background(
|
||||||
Group {
|
Group {
|
||||||
if isUser {
|
if isUser {
|
||||||
@@ -243,7 +269,6 @@ private struct ToolCallActivityChip: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.textSelection(.enabled)
|
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.background(
|
.background(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -26,322 +26,54 @@ struct SybilPhoneShellView: View {
|
|||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var shouldRefreshOnForeground = false
|
@State private var shouldRefreshOnForeground = false
|
||||||
@State private var composerFocusRequest = 0
|
@State private var composerFocusRequest = 0
|
||||||
@State private var phoneStackWidth: CGFloat = BackSwipeMetrics.referenceWidth
|
|
||||||
@State private var backSwipeOffset: CGFloat = 0
|
|
||||||
@State private var backSwipeCompletionOffset: CGFloat = 0
|
|
||||||
@State private var backSwipeIsActive = false
|
|
||||||
@State private var backSwipeIsCompleting = false
|
|
||||||
@State private var backSwipeHasLatched = false
|
|
||||||
|
|
||||||
private var canRecognizeBackSwipe: Bool {
|
|
||||||
!path.isEmpty && !backSwipeIsCompleting
|
|
||||||
}
|
|
||||||
|
|
||||||
private var backSwipeVisualOffset: CGFloat {
|
|
||||||
backSwipeOffset + backSwipeCompletionOffset
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { proxy in
|
NavigationStack(path: $path) {
|
||||||
ZStack(alignment: .topLeading) {
|
|
||||||
SybilPhoneSidebarRoot(viewModel: viewModel, path: $path)
|
SybilPhoneSidebarRoot(viewModel: viewModel, path: $path)
|
||||||
.safeAreaInset(edge: .top, spacing: 0) {
|
.navigationTitle("")
|
||||||
phoneRootTopBar
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
SybilWordmark(size: 18)
|
||||||
}
|
}
|
||||||
.zIndex(0)
|
}
|
||||||
|
.navigationDestination(for: PhoneRoute.self) { route in
|
||||||
if let route = path.last {
|
|
||||||
SybilPhoneDestinationView(
|
SybilPhoneDestinationView(
|
||||||
viewModel: viewModel,
|
viewModel: viewModel,
|
||||||
|
path: $path,
|
||||||
composerFocusRequest: $composerFocusRequest,
|
composerFocusRequest: $composerFocusRequest,
|
||||||
route: route,
|
route: route
|
||||||
onRequestBack: requestBack,
|
|
||||||
onRequestNewChat: startNewChatFromDestination
|
|
||||||
)
|
)
|
||||||
.background(SybilTheme.background)
|
|
||||||
.offset(x: backSwipeVisualOffset)
|
|
||||||
.shadow(
|
|
||||||
color: backSwipeVisualOffset > 0 ? Color.black.opacity(0.34) : Color.clear,
|
|
||||||
radius: backSwipeVisualOffset > 0 ? 18 : 0,
|
|
||||||
x: -8,
|
|
||||||
y: 0
|
|
||||||
)
|
|
||||||
.transition(.move(edge: .trailing))
|
|
||||||
.zIndex(1)
|
|
||||||
.background {
|
|
||||||
WorkspaceSwipePanInstaller(
|
|
||||||
direction: .right,
|
|
||||||
isEnabled: canRecognizeBackSwipe,
|
|
||||||
onBegan: { width in
|
|
||||||
beginBackSwipe(containerWidth: width)
|
|
||||||
},
|
|
||||||
onChanged: { translationX, width in
|
|
||||||
updateBackSwipe(with: translationX, containerWidth: width)
|
|
||||||
},
|
|
||||||
onEnded: { translationX, width, velocityX, didFinish in
|
|
||||||
finishBackSwipe(
|
|
||||||
translationX: translationX,
|
|
||||||
containerWidth: width,
|
|
||||||
velocityX: velocityX,
|
|
||||||
didFinish: didFinish
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
||||||
.onAppear {
|
|
||||||
updatePhoneStackWidth(proxy.size.width)
|
|
||||||
}
|
|
||||||
.onChange(of: proxy.size.width) { _, width in
|
|
||||||
updatePhoneStackWidth(width)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(SybilTheme.primary)
|
.tint(SybilTheme.primary)
|
||||||
.animation(.easeOut(duration: 0.22), value: path.last)
|
|
||||||
.onChange(of: path) { _, nextPath in
|
|
||||||
guard nextPath.isEmpty else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resetBackSwipe(animated: false)
|
|
||||||
}
|
|
||||||
.onChange(of: scenePhase) { _, nextPhase in
|
.onChange(of: scenePhase) { _, nextPhase in
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var phoneRootTopBar: some View {
|
|
||||||
HStack {
|
|
||||||
SybilWordmark(size: 21)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.top, 10)
|
|
||||||
.padding(.bottom, 12)
|
|
||||||
.background {
|
|
||||||
SybilTheme.panelGradient
|
|
||||||
.ignoresSafeArea(edges: .top)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updatePhoneStackWidth(_ width: CGFloat) {
|
|
||||||
phoneStackWidth = max(width, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func requestBack(animateNavigation: Bool = true) {
|
|
||||||
guard !path.isEmpty, !backSwipeIsCompleting else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if animateNavigation {
|
|
||||||
Task {
|
|
||||||
await completeBackSwipe(containerWidth: phoneStackWidth, releaseVelocityX: 0)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
popRoute(disablesAnimations: true)
|
|
||||||
resetBackSwipe(animated: false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startNewChatFromDestination() {
|
|
||||||
viewModel.startNewChat()
|
|
||||||
composerFocusRequest += 1
|
|
||||||
replaceTopRoute(with: .draftChat)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func replaceTopRoute(with route: PhoneRoute) {
|
|
||||||
if path.isEmpty {
|
|
||||||
withAnimation(.easeOut(duration: 0.22)) {
|
|
||||||
path = [route]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
path[path.index(before: path.endIndex)] = route
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func popRoute(disablesAnimations: Bool) {
|
|
||||||
let pop = {
|
|
||||||
guard !path.isEmpty else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = path.removeLast()
|
|
||||||
}
|
|
||||||
|
|
||||||
if disablesAnimations {
|
|
||||||
var transaction = Transaction()
|
|
||||||
transaction.disablesAnimations = true
|
|
||||||
withTransaction(transaction) {
|
|
||||||
pop()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
withAnimation(.easeOut(duration: 0.22)) {
|
|
||||||
pop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func beginBackSwipe(containerWidth: CGFloat) {
|
|
||||||
let update = {
|
|
||||||
backSwipeIsActive = true
|
|
||||||
backSwipeHasLatched = false
|
|
||||||
}
|
|
||||||
|
|
||||||
var transaction = Transaction()
|
|
||||||
transaction.disablesAnimations = true
|
|
||||||
withTransaction(transaction, update)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateBackSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) {
|
|
||||||
let nextOffset = BackSwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth)
|
|
||||||
let nextLatched = BackSwipeMetrics.isLatched(
|
|
||||||
offset: nextOffset,
|
|
||||||
width: containerWidth,
|
|
||||||
isCurrentlyLatched: backSwipeHasLatched
|
|
||||||
)
|
|
||||||
|
|
||||||
var transaction = Transaction()
|
|
||||||
transaction.disablesAnimations = true
|
|
||||||
withTransaction(transaction) {
|
|
||||||
backSwipeOffset = nextOffset
|
|
||||||
backSwipeHasLatched = nextLatched
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func finishBackSwipe(
|
|
||||||
translationX: CGFloat,
|
|
||||||
containerWidth: CGFloat,
|
|
||||||
velocityX: CGFloat,
|
|
||||||
didFinish: Bool
|
|
||||||
) {
|
|
||||||
guard backSwipeIsActive else {
|
|
||||||
resetBackSwipe(animated: false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let finalOffset = BackSwipeMetrics.clampedOffset(for: translationX, width: containerWidth)
|
|
||||||
let finalLatched = BackSwipeMetrics.isLatched(
|
|
||||||
offset: finalOffset,
|
|
||||||
width: containerWidth,
|
|
||||||
isCurrentlyLatched: backSwipeHasLatched
|
|
||||||
)
|
|
||||||
updateBackSwipe(with: translationX, containerWidth: containerWidth)
|
|
||||||
|
|
||||||
if didFinish && BackSwipeMetrics.shouldComplete(
|
|
||||||
offset: finalOffset,
|
|
||||||
velocityX: velocityX,
|
|
||||||
width: containerWidth,
|
|
||||||
isLatched: finalLatched
|
|
||||||
) {
|
|
||||||
Task {
|
|
||||||
await completeBackSwipe(containerWidth: containerWidth, releaseVelocityX: velocityX)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resetBackSwipe(animated: true, velocityX: velocityX)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func completeBackSwipe(containerWidth: CGFloat, releaseVelocityX: CGFloat) async {
|
|
||||||
guard !path.isEmpty else {
|
|
||||||
resetBackSwipe(animated: false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard !backSwipeIsCompleting else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
backSwipeIsCompleting = true
|
|
||||||
let targetOffset = BackSwipeMetrics.completionTargetOffset(for: containerWidth)
|
|
||||||
|
|
||||||
withAnimation(
|
|
||||||
BackSwipeMetrics.springAnimation(
|
|
||||||
currentOffset: backSwipeOffset,
|
|
||||||
targetOffset: targetOffset,
|
|
||||||
velocityX: releaseVelocityX
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
backSwipeCompletionOffset = targetOffset - backSwipeOffset
|
|
||||||
}
|
|
||||||
|
|
||||||
try? await Task.sleep(for: .milliseconds(BackSwipeMetrics.completionAnimationDelayMs))
|
|
||||||
popRoute(disablesAnimations: true)
|
|
||||||
resetBackSwipe(animated: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resetBackSwipe(animated: Bool, velocityX: CGFloat = 0) {
|
|
||||||
let currentOffset = backSwipeOffset + backSwipeCompletionOffset
|
|
||||||
let reset = {
|
|
||||||
backSwipeOffset = 0
|
|
||||||
backSwipeCompletionOffset = 0
|
|
||||||
backSwipeIsActive = false
|
|
||||||
backSwipeIsCompleting = false
|
|
||||||
backSwipeHasLatched = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if animated {
|
|
||||||
withAnimation(
|
|
||||||
BackSwipeMetrics.springAnimation(
|
|
||||||
currentOffset: currentOffset,
|
|
||||||
targetOffset: 0,
|
|
||||||
velocityX: velocityX
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
reset()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
reset()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SybilPhoneSidebarRoot: View {
|
private struct SybilPhoneSidebarRoot: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
@Binding var path: [PhoneRoute]
|
@Binding var path: [PhoneRoute]
|
||||||
@State private var openingSelection: SidebarSelection?
|
|
||||||
@State private var openingRequestID: UUID?
|
|
||||||
|
|
||||||
private var highlightedSelection: SidebarSelection? {
|
|
||||||
if let openingSelection {
|
|
||||||
return openingSelection
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let route = path.last else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch route {
|
|
||||||
case let .chat(chatID):
|
|
||||||
return .chat(chatID)
|
|
||||||
case let .search(searchID):
|
|
||||||
return .search(searchID)
|
|
||||||
case .draftChat, .draftSearch, .settings:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -381,21 +113,12 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
.padding(16)
|
.padding(16)
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 0) {
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(viewModel.sidebarItems) { item in
|
ForEach(viewModel.sidebarItems) { item in
|
||||||
Button {
|
NavigationLink(value: PhoneRoute.from(selection: item.selection)) {
|
||||||
open(item.selection)
|
|
||||||
} label: {
|
|
||||||
VStack(spacing: 0.0) {
|
|
||||||
SybilPhoneSidebarRow(item: item)
|
SybilPhoneSidebarRow(item: item)
|
||||||
Divider()
|
|
||||||
}
|
}
|
||||||
}
|
.buttonStyle(.plain)
|
||||||
.buttonStyle(
|
|
||||||
SybilPhoneSidebarRowButtonStyle(
|
|
||||||
isHighlighted: highlightedSelection == item.selection
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
Task {
|
Task {
|
||||||
@@ -407,13 +130,12 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(10)
|
||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await viewModel.refreshVisibleContent(
|
await viewModel.refreshCollectionsFromUser()
|
||||||
refreshCollections: true,
|
|
||||||
refreshSelection: false
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(SybilTheme.panelGradient)
|
.background(SybilTheme.panelGradient)
|
||||||
@@ -429,22 +151,19 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
|
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
|
||||||
clearOpeningSelection()
|
path = [.settings]
|
||||||
showRoute(.settings)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
|
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
|
||||||
clearOpeningSelection()
|
|
||||||
viewModel.startNewSearch()
|
viewModel.startNewSearch()
|
||||||
showRoute(.draftSearch)
|
path = [.draftSearch]
|
||||||
}
|
}
|
||||||
|
|
||||||
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
|
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
|
||||||
clearOpeningSelection()
|
|
||||||
viewModel.startNewChat()
|
viewModel.startNewChat()
|
||||||
showRoute(.draftChat)
|
path = [.draftChat]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 18)
|
.padding(.horizontal, 18)
|
||||||
@@ -480,75 +199,25 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(accessibilityLabel)
|
.accessibilityLabel(accessibilityLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func clearOpeningSelection() {
|
|
||||||
openingRequestID = nil
|
|
||||||
openingSelection = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func showRoute(_ route: PhoneRoute) {
|
|
||||||
withAnimation(.easeOut(duration: 0.22)) {
|
|
||||||
path = [route]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func open(_ selection: SidebarSelection) {
|
|
||||||
guard openingSelection != selection else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let requestID = UUID()
|
|
||||||
openingRequestID = requestID
|
|
||||||
openingSelection = selection
|
|
||||||
Task {
|
|
||||||
await viewModel.selectForNavigation(selection)
|
|
||||||
guard openingRequestID == requestID else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
showRoute(PhoneRoute.from(selection: selection))
|
|
||||||
openingRequestID = nil
|
|
||||||
openingSelection = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SybilPhoneSidebarRowIsActiveKey: EnvironmentKey {
|
|
||||||
static let defaultValue = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension EnvironmentValues {
|
|
||||||
var sybilPhoneSidebarRowIsActive: Bool {
|
|
||||||
get { self[SybilPhoneSidebarRowIsActiveKey.self] }
|
|
||||||
set { self[SybilPhoneSidebarRowIsActiveKey.self] = newValue }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SybilPhoneSidebarRowButtonStyle: ButtonStyle {
|
|
||||||
var isHighlighted: Bool
|
|
||||||
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
|
||||||
configuration.label
|
|
||||||
.environment(\.sybilPhoneSidebarRowIsActive, isHighlighted || configuration.isPressed)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SybilPhoneSidebarRow: View {
|
private struct SybilPhoneSidebarRow: View {
|
||||||
@Environment(\.sybilPhoneSidebarRowIsActive) private var isHighlighted
|
|
||||||
var item: SidebarItem
|
var item: SidebarItem
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let leadingWidth = 22.0
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: item.kind == .chat ? "message" : "globe")
|
Image(systemName: item.kind == .chat ? "message" : "globe")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.system(size: 12, weight: .semibold))
|
||||||
.foregroundStyle(isHighlighted ? SybilTheme.accent : SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
.frame(width: leadingWidth, height: leadingWidth)
|
.frame(width: 22, height: 22)
|
||||||
.background(
|
.background(
|
||||||
Rectangle()
|
RoundedRectangle(cornerRadius: 7)
|
||||||
.fill(isHighlighted ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
|
.fill(SybilTheme.surface.opacity(0.72))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 7)
|
||||||
|
.stroke(SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(item.title)
|
Text(item.title)
|
||||||
@@ -557,9 +226,6 @@ private struct SybilPhoneSidebarRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Spacer()
|
|
||||||
.frame(width: leadingWidth)
|
|
||||||
|
|
||||||
Text(item.updatedAt.sybilRelativeLabel)
|
Text(item.updatedAt.sybilRelativeLabel)
|
||||||
.font(.sybil(.caption2))
|
.font(.sybil(.caption2))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
@@ -576,35 +242,38 @@ private struct SybilPhoneSidebarRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundStyle(SybilTheme.text)
|
.foregroundStyle(SybilTheme.text)
|
||||||
.padding(18.0)
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(
|
.background(
|
||||||
Rectangle()
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.fill(
|
.fill(LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||||
isHighlighted
|
|
||||||
? SybilTheme.selectedRowGradient
|
|
||||||
: LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing)
|
|
||||||
)
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SybilPhoneDestinationView: View {
|
private struct SybilPhoneDestinationView: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
|
@Binding var path: [PhoneRoute]
|
||||||
@Binding var composerFocusRequest: Int
|
@Binding var composerFocusRequest: Int
|
||||||
let route: PhoneRoute
|
let route: PhoneRoute
|
||||||
let onRequestBack: (_ animateNavigation: Bool) -> Void
|
|
||||||
let onRequestNewChat: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
SybilWorkspaceView(
|
SybilWorkspaceView(viewModel: viewModel, composerFocusRequest: composerFocusRequest) {
|
||||||
viewModel: viewModel,
|
viewModel.startNewChat()
|
||||||
composerFocusRequest: composerFocusRequest,
|
composerFocusRequest += 1
|
||||||
onRequestBack: onRequestBack,
|
if path.isEmpty {
|
||||||
onRequestNewChat: onRequestNewChat
|
path = [.draftChat]
|
||||||
)
|
} else {
|
||||||
|
path[path.index(before: path.endIndex)] = .draftChat
|
||||||
|
}
|
||||||
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.task(id: route) {
|
.task(id: route) {
|
||||||
applyRoute()
|
applyRoute()
|
||||||
}
|
}
|
||||||
@@ -613,14 +282,8 @@ private struct SybilPhoneDestinationView: View {
|
|||||||
private func applyRoute() {
|
private func applyRoute() {
|
||||||
switch route {
|
switch route {
|
||||||
case let .chat(chatID):
|
case let .chat(chatID):
|
||||||
guard viewModel.draftKind != nil || viewModel.selectedItem != .chat(chatID) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
viewModel.select(.chat(chatID))
|
viewModel.select(.chat(chatID))
|
||||||
case let .search(searchID):
|
case let .search(searchID):
|
||||||
guard viewModel.draftKind != nil || viewModel.selectedItem != .search(searchID) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
viewModel.select(.search(searchID))
|
viewModel.select(.search(searchID))
|
||||||
case .draftChat:
|
case .draftChat:
|
||||||
viewModel.startNewChat()
|
viewModel.startNewChat()
|
||||||
|
|||||||
@@ -6,8 +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 topContentInset: CGFloat = 0
|
var onRefresh: (() async -> Void)? = nil
|
||||||
var bottomContentInset: CGFloat = 0
|
|
||||||
var onStartChat: (() -> Void)? = nil
|
var onStartChat: (() -> Void)? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -100,9 +99,12 @@ struct SybilSearchResultsView: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.top, 20 + topContentInset)
|
.padding(.vertical, 20)
|
||||||
.padding(.bottom, 20 + bottomContentInset)
|
|
||||||
}
|
}
|
||||||
|
.refreshable {
|
||||||
|
await onRefresh?()
|
||||||
|
}
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,11 +150,9 @@ struct SybilSidebarView: View {
|
|||||||
.padding(10)
|
.padding(10)
|
||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await viewModel.refreshVisibleContent(
|
await viewModel.refreshCollectionsFromUser()
|
||||||
refreshCollections: true,
|
|
||||||
refreshSelection: false
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ enum SybilFontRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static let registeredFonts: Void = {
|
private static let registeredFonts: Void = {
|
||||||
for fontName in ["Inter", "Orbitron", "StalinistOne-Regular"] {
|
for fontName in ["Inter", "Orbitron"] {
|
||||||
guard let url = Bundle.main.url(forResource: fontName, withExtension: "ttf", subdirectory: "Fonts") ??
|
guard let url = Bundle.main.url(forResource: fontName, withExtension: "ttf", subdirectory: "Fonts") ??
|
||||||
Bundle.main.url(forResource: fontName, withExtension: "ttf")
|
Bundle.main.url(forResource: fontName, withExtension: "ttf")
|
||||||
else {
|
else {
|
||||||
@@ -203,7 +203,7 @@ struct SybilWordmark: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text("SYBIL")
|
Text("SYBIL")
|
||||||
.font(.custom("Stalinist One", size: size))
|
.font(.custom("Orbitron", size: size))
|
||||||
.fontWeight(.black)
|
.fontWeight(.black)
|
||||||
.tracking(0)
|
.tracking(0)
|
||||||
.foregroundStyle(SybilTheme.brandGradient)
|
.foregroundStyle(SybilTheme.brandGradient)
|
||||||
|
|||||||
@@ -42,22 +42,6 @@ private struct PendingChatState {
|
|||||||
var messages: [Message]
|
var messages: [Message]
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum ActiveSendContext: Equatable {
|
|
||||||
case draftChat(UUID)
|
|
||||||
case chat(String)
|
|
||||||
case draftSearch(UUID)
|
|
||||||
case search(String)
|
|
||||||
|
|
||||||
var isSearch: Bool {
|
|
||||||
switch self {
|
|
||||||
case .draftSearch, .search:
|
|
||||||
return true
|
|
||||||
case .draftChat, .chat:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private actor CompletionStreamStatus {
|
private actor CompletionStreamStatus {
|
||||||
private var streamError: String?
|
private var streamError: String?
|
||||||
|
|
||||||
@@ -115,17 +99,11 @@ final class SybilViewModel {
|
|||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private var hasBootstrapped = false
|
private var hasBootstrapped = false
|
||||||
private var pendingChatState: PendingChatState?
|
private var pendingChatState: PendingChatState?
|
||||||
private var activeSendContext: ActiveSendContext?
|
|
||||||
private var draftIdentity = UUID()
|
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private var selectionTask: Task<Void, Never>?
|
private var selectionTask: Task<Void, Never>?
|
||||||
@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]] = [
|
||||||
@@ -175,7 +153,7 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
switch selectedItem {
|
switch selectedItem {
|
||||||
case .chat:
|
case .chat:
|
||||||
if let selectedChat = currentSelectedChat {
|
if let selectedChat {
|
||||||
return chatTitle(title: selectedChat.title, messages: selectedChat.messages)
|
return chatTitle(title: selectedChat.title, messages: selectedChat.messages)
|
||||||
}
|
}
|
||||||
if let summary = selectedChatSummary {
|
if let summary = selectedChatSummary {
|
||||||
@@ -184,7 +162,7 @@ final class SybilViewModel {
|
|||||||
return "Chat"
|
return "Chat"
|
||||||
|
|
||||||
case .search:
|
case .search:
|
||||||
if let selectedSearch = currentSelectedSearch {
|
if let selectedSearch {
|
||||||
return searchTitle(title: selectedSearch.title, query: selectedSearch.query)
|
return searchTitle(title: selectedSearch.title, query: selectedSearch.query)
|
||||||
}
|
}
|
||||||
if let summary = selectedSearchSummary {
|
if let summary = selectedSearchSummary {
|
||||||
@@ -251,7 +229,7 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var displayedMessages: [Message] {
|
var displayedMessages: [Message] {
|
||||||
let canonical = displayableMessages(currentSelectedChat?.messages ?? [])
|
let canonical = displayableMessages(selectedChat?.messages ?? [])
|
||||||
guard let pending = pendingChatState else {
|
guard let pending = pendingChatState else {
|
||||||
return canonical
|
return canonical
|
||||||
}
|
}
|
||||||
@@ -270,40 +248,6 @@ final class SybilViewModel {
|
|||||||
return canonical
|
return canonical
|
||||||
}
|
}
|
||||||
|
|
||||||
var displayedSearch: SearchDetail? {
|
|
||||||
currentSelectedSearch
|
|
||||||
}
|
|
||||||
|
|
||||||
var isSendingVisibleChat: Bool {
|
|
||||||
guard isSending, pendingChatState != nil else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
switch activeSendContext {
|
|
||||||
case let .draftChat(identity):
|
|
||||||
return draftKind == .chat && identity == draftIdentity
|
|
||||||
case let .chat(chatID):
|
|
||||||
return selectedItem == .chat(chatID)
|
|
||||||
case .draftSearch, .search, nil:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isRunningVisibleSearch: Bool {
|
|
||||||
guard isSending else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
switch activeSendContext {
|
|
||||||
case let .draftSearch(identity):
|
|
||||||
return draftKind == .search && identity == draftIdentity
|
|
||||||
case let .search(searchID):
|
|
||||||
return selectedItem == .search(searchID)
|
|
||||||
case .draftChat, .chat, nil:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var sidebarItems: [SidebarItem] {
|
var sidebarItems: [SidebarItem] {
|
||||||
let chatItems: [SidebarItem] = chats.map { chat in
|
let chatItems: [SidebarItem] = chats.map { chat in
|
||||||
let initiatedLabel: String?
|
let initiatedLabel: String?
|
||||||
@@ -332,7 +276,7 @@ final class SybilViewModel {
|
|||||||
kind: .search,
|
kind: .search,
|
||||||
title: searchTitle(title: search.title, query: search.query),
|
title: searchTitle(title: search.title, query: search.query),
|
||||||
updatedAt: search.updatedAt,
|
updatedAt: search.updatedAt,
|
||||||
initiatedLabel: "exa"
|
initiatedLabel: nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,10 +320,7 @@ final class SybilViewModel {
|
|||||||
isCheckingSession = true
|
isCheckingSession = true
|
||||||
authError = nil
|
authError = nil
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
resetSelectionLoading()
|
|
||||||
pendingChatState = nil
|
pendingChatState = nil
|
||||||
activeSendContext = nil
|
|
||||||
draftIdentity = UUID()
|
|
||||||
composerAttachments = []
|
composerAttachments = []
|
||||||
settings.persist()
|
settings.persist()
|
||||||
|
|
||||||
@@ -451,13 +392,10 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
func startNewChat() {
|
func startNewChat() {
|
||||||
SybilLog.debug(SybilLog.ui, "Starting draft chat")
|
SybilLog.debug(SybilLog.ui, "Starting draft chat")
|
||||||
resetSelectionLoading()
|
|
||||||
draftIdentity = UUID()
|
|
||||||
draftKind = .chat
|
draftKind = .chat
|
||||||
selectedItem = nil
|
selectedItem = nil
|
||||||
selectedChat = nil
|
selectedChat = nil
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
pendingChatState = nil
|
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
composer = ""
|
composer = ""
|
||||||
composerAttachments = []
|
composerAttachments = []
|
||||||
@@ -465,13 +403,10 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
func startNewSearch() {
|
func startNewSearch() {
|
||||||
SybilLog.debug(SybilLog.ui, "Starting draft search")
|
SybilLog.debug(SybilLog.ui, "Starting draft search")
|
||||||
resetSelectionLoading()
|
|
||||||
draftIdentity = UUID()
|
|
||||||
draftKind = .search
|
draftKind = .search
|
||||||
selectedItem = nil
|
selectedItem = nil
|
||||||
selectedChat = nil
|
selectedChat = nil
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
pendingChatState = nil
|
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
composer = ""
|
composer = ""
|
||||||
composerAttachments = []
|
composerAttachments = []
|
||||||
@@ -479,72 +414,33 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
func openSettings() {
|
func openSettings() {
|
||||||
SybilLog.debug(SybilLog.ui, "Opening settings")
|
SybilLog.debug(SybilLog.ui, "Opening settings")
|
||||||
resetSelectionLoading()
|
|
||||||
draftKind = nil
|
draftKind = nil
|
||||||
selectedItem = .settings
|
selectedItem = .settings
|
||||||
selectedChat = nil
|
selectedChat = nil
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
pendingChatState = nil
|
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
composerAttachments = []
|
composerAttachments = []
|
||||||
}
|
}
|
||||||
|
|
||||||
func select(_ selection: SidebarSelection) {
|
func select(_ selection: SidebarSelection) {
|
||||||
_ = beginSelecting(selection)
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectForNavigation(_ selection: SidebarSelection, preloadTimeout: Duration = .seconds(3)) async {
|
|
||||||
guard beginSelecting(selection) != nil else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await waitForSelectionLoad(timeout: preloadTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
private func beginSelecting(_ selection: SidebarSelection) -> Task<Void, Never>? {
|
|
||||||
SybilLog.debug(SybilLog.ui, "Selecting \(selection.id)")
|
SybilLog.debug(SybilLog.ui, "Selecting \(selection.id)")
|
||||||
|
draftKind = nil
|
||||||
if draftKind == nil, selectedItem == selection {
|
selectedItem = selection
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
if case .search = selection {
|
if case .search = selection {
|
||||||
composerAttachments = []
|
composerAttachments = []
|
||||||
}
|
}
|
||||||
|
|
||||||
if needsSelectionLoad(selection) {
|
if case .settings = selection {
|
||||||
return startSelectionRefreshTask()
|
|
||||||
}
|
|
||||||
|
|
||||||
return selectionTask
|
|
||||||
}
|
|
||||||
|
|
||||||
resetSelectionLoading()
|
|
||||||
draftKind = nil
|
|
||||||
selectedItem = selection
|
|
||||||
errorMessage = nil
|
|
||||||
|
|
||||||
switch selection {
|
|
||||||
case let .chat(chatID):
|
|
||||||
if selectedChat?.id != chatID {
|
|
||||||
selectedChat = nil
|
|
||||||
}
|
|
||||||
selectedSearch = nil
|
|
||||||
|
|
||||||
case let .search(searchID):
|
|
||||||
if selectedSearch?.id != searchID {
|
|
||||||
selectedSearch = nil
|
|
||||||
}
|
|
||||||
selectedChat = nil
|
|
||||||
composerAttachments = []
|
|
||||||
|
|
||||||
case .settings:
|
|
||||||
selectedChat = nil
|
selectedChat = nil
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
pendingChatState = nil
|
return
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return startSelectionRefreshTask()
|
selectionTask?.cancel()
|
||||||
|
selectionTask = Task { [weak self] in
|
||||||
|
await self?.refreshSelectionIfNeeded()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectPreviousSidebarItem() {
|
func selectPreviousSidebarItem() {
|
||||||
@@ -575,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
|
||||||
@@ -606,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
|
||||||
@@ -649,7 +541,7 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
SybilLog.info(
|
SybilLog.info(
|
||||||
SybilLog.ui,
|
SybilLog.ui,
|
||||||
"Visible content refresh requested (collections=\(shouldRefreshCollections), selection=\(shouldRefreshSelection))"
|
"Foreground refresh requested (collections=\(shouldRefreshCollections), selection=\(shouldRefreshSelection))"
|
||||||
)
|
)
|
||||||
|
|
||||||
if shouldRefreshCollections {
|
if shouldRefreshCollections {
|
||||||
@@ -665,13 +557,12 @@ final class SybilViewModel {
|
|||||||
func sendComposer() async {
|
func sendComposer() async {
|
||||||
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let attachments = composerAttachments
|
let attachments = composerAttachments
|
||||||
let sendContext = currentSendContext
|
|
||||||
|
|
||||||
guard !isSending else {
|
guard !isSending else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if sendContext.isSearch {
|
if isSearchMode {
|
||||||
guard !content.isEmpty else { return }
|
guard !content.isEmpty else { return }
|
||||||
} else if content.isEmpty && attachments.isEmpty {
|
} else if content.isEmpty && attachments.isEmpty {
|
||||||
return
|
return
|
||||||
@@ -680,11 +571,10 @@ final class SybilViewModel {
|
|||||||
composer = ""
|
composer = ""
|
||||||
composerAttachments = []
|
composerAttachments = []
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
activeSendContext = sendContext
|
|
||||||
isSending = true
|
isSending = true
|
||||||
|
|
||||||
do {
|
do {
|
||||||
if sendContext.isSearch {
|
if isSearchMode {
|
||||||
SybilLog.info(SybilLog.ui, "Sending search query")
|
SybilLog.info(SybilLog.ui, "Sending search query")
|
||||||
try await sendSearch(query: content)
|
try await sendSearch(query: content)
|
||||||
} else {
|
} else {
|
||||||
@@ -692,43 +582,35 @@ final class SybilViewModel {
|
|||||||
try await sendChat(content: content, attachments: attachments)
|
try await sendChat(content: content, attachments: attachments)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
let shouldSurfaceError = isSendContextVisible(sendContext) || (activeSendContext.map { isSendContextVisible($0) } ?? false)
|
|
||||||
if shouldSurfaceError {
|
|
||||||
errorMessage = normalizeAPIError(error)
|
errorMessage = normalizeAPIError(error)
|
||||||
}
|
|
||||||
SybilLog.error(SybilLog.ui, "Send failed", error: error)
|
SybilLog.error(SybilLog.ui, "Send failed", error: error)
|
||||||
|
|
||||||
if shouldSurfaceError, case let .chat(chatID) = selectedItem {
|
if case let .chat(chatID) = selectedItem {
|
||||||
do {
|
do {
|
||||||
let chat = try await client().getChat(chatID: chatID)
|
let chat = try await client().getChat(chatID: chatID)
|
||||||
if selectedItem == .chat(chatID), draftKind == nil {
|
|
||||||
selectedChat = chat
|
selectedChat = chat
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
SybilLog.error(SybilLog.ui, "Fallback chat refresh after failure failed", error: error)
|
SybilLog.error(SybilLog.ui, "Fallback chat refresh after failure failed", error: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if shouldSurfaceError, case let .search(searchID) = selectedItem {
|
if case let .search(searchID) = selectedItem {
|
||||||
do {
|
do {
|
||||||
let search = try await client().getSearch(searchID: searchID)
|
let search = try await client().getSearch(searchID: searchID)
|
||||||
if selectedItem == .search(searchID), draftKind == nil {
|
|
||||||
selectedSearch = search
|
selectedSearch = search
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
SybilLog.error(SybilLog.ui, "Fallback search refresh after failure failed", error: error)
|
SybilLog.error(SybilLog.ui, "Fallback search refresh after failure failed", error: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !sendContext.isSearch, shouldSurfaceError {
|
if !isSearchMode {
|
||||||
composer = content
|
composer = content
|
||||||
composerAttachments = attachments
|
composerAttachments = attachments
|
||||||
}
|
|
||||||
pendingChatState = nil
|
pendingChatState = nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
isSending = false
|
isSending = false
|
||||||
activeSendContext = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func appendComposerAttachments(_ attachments: [ChatAttachment]) throws {
|
func appendComposerAttachments(_ attachments: [ChatAttachment]) throws {
|
||||||
@@ -754,25 +636,16 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startChatFromSelectedSearch() async {
|
func startChatFromSelectedSearch() async {
|
||||||
guard let search = currentSelectedSearch, !isCreatingSearchChat, !isSending else {
|
guard let search = selectedSearch, !isCreatingSearchChat, !isSending else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let sourceSelection = SidebarSelection.search(search.id)
|
|
||||||
isCreatingSearchChat = true
|
isCreatingSearchChat = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let client = try client()
|
let client = try client()
|
||||||
let chat = try await client.createChatFromSearch(searchID: search.id, title: nil)
|
let chat = try await client.createChatFromSearch(searchID: search.id, title: nil)
|
||||||
|
|
||||||
guard selectedItem == sourceSelection, draftKind == nil else {
|
|
||||||
chats.removeAll(where: { $0.id == chat.id })
|
|
||||||
chats.insert(chat, at: 0)
|
|
||||||
isCreatingSearchChat = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
draftKind = nil
|
draftKind = nil
|
||||||
pendingChatState = nil
|
pendingChatState = nil
|
||||||
composer = ""
|
composer = ""
|
||||||
@@ -865,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
|
||||||
@@ -884,12 +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 draftKind != nil {
|
|
||||||
isLoadingCollections = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if case .settings = selectedItem {
|
if case .settings = selectedItem {
|
||||||
isLoadingCollections = false
|
isLoadingCollections = false
|
||||||
@@ -910,79 +801,33 @@ final class SybilViewModel {
|
|||||||
await refreshSelectionIfNeeded()
|
await refreshSelectionIfNeeded()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if shouldSuppressInactiveTransportError(error) {
|
|
||||||
SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive")
|
|
||||||
} else {
|
|
||||||
errorMessage = normalizeAPIError(error)
|
errorMessage = normalizeAPIError(error)
|
||||||
SybilLog.error(SybilLog.app, "Refresh collections failed", error: error)
|
SybilLog.error(SybilLog.app, "Refresh collections failed", error: error)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
isLoadingCollections = false
|
isLoadingCollections = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resetSelectionLoading() {
|
|
||||||
selectionTask?.cancel()
|
|
||||||
selectionTask = nil
|
|
||||||
isLoadingSelection = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startSelectionRefreshTask() -> Task<Void, Never> {
|
|
||||||
isLoadingSelection = true
|
|
||||||
let task = Task { [weak self] in
|
|
||||||
guard let self else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await self.refreshSelectionIfNeeded()
|
|
||||||
}
|
|
||||||
selectionTask = task
|
|
||||||
return task
|
|
||||||
}
|
|
||||||
|
|
||||||
private func waitForSelectionLoad(timeout: Duration) async {
|
|
||||||
let clock = ContinuousClock()
|
|
||||||
let deadline = clock.now.advanced(by: timeout)
|
|
||||||
|
|
||||||
while isLoadingSelection, clock.now < deadline {
|
|
||||||
try? await Task.sleep(for: .milliseconds(10))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func needsSelectionLoad(_ selection: SidebarSelection) -> Bool {
|
|
||||||
switch selection {
|
|
||||||
case let .chat(chatID):
|
|
||||||
return selectedChat?.id != chatID
|
|
||||||
case let .search(searchID):
|
|
||||||
return selectedSearch?.id != searchID
|
|
||||||
case .settings:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refreshSelectionIfNeeded() async {
|
private func refreshSelectionIfNeeded() async {
|
||||||
guard let target = selectedItem else {
|
guard let selectedItem else {
|
||||||
selectedChat = nil
|
selectedChat = nil
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
isLoadingSelection = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard case .settings = target else {
|
guard case .settings = selectedItem else {
|
||||||
isLoadingSelection = true
|
isLoadingSelection = true
|
||||||
do {
|
do {
|
||||||
let client = try client()
|
let client = try client()
|
||||||
switch target {
|
switch selectedItem {
|
||||||
case let .chat(chatID):
|
case let .chat(chatID):
|
||||||
SybilLog.debug(SybilLog.app, "Refreshing chat \(chatID)")
|
SybilLog.debug(SybilLog.app, "Refreshing chat \(chatID)")
|
||||||
let chat = try await client.getChat(chatID: chatID)
|
selectedChat = try await client.getChat(chatID: chatID)
|
||||||
guard selectedItem == target, draftKind == nil else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
selectedChat = chat
|
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
|
|
||||||
if let provider = chat.lastUsedProvider,
|
if let detail = selectedChat,
|
||||||
let model = chat.lastUsedModel,
|
let provider = detail.lastUsedProvider,
|
||||||
|
let model = detail.lastUsedModel,
|
||||||
!model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
!model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
self.model = model
|
self.model = model
|
||||||
@@ -990,37 +835,26 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
case let .search(searchID):
|
case let .search(searchID):
|
||||||
SybilLog.debug(SybilLog.app, "Refreshing search \(searchID)")
|
SybilLog.debug(SybilLog.app, "Refreshing search \(searchID)")
|
||||||
let search = try await client.getSearch(searchID: searchID)
|
selectedSearch = try await client.getSearch(searchID: searchID)
|
||||||
guard selectedItem == target, draftKind == nil else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
selectedSearch = search
|
|
||||||
selectedChat = nil
|
selectedChat = nil
|
||||||
|
|
||||||
case .settings:
|
case .settings:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
errorMessage = nil
|
|
||||||
} catch {
|
} catch {
|
||||||
if isCancellation(error) {
|
if isCancellation(error) {
|
||||||
SybilLog.debug(SybilLog.app, "Selection refresh cancelled for \(target.id)")
|
SybilLog.debug(SybilLog.app, "Selection refresh cancelled for \(selectedItem.id)")
|
||||||
} else if shouldSuppressInactiveTransportError(error) {
|
} else {
|
||||||
SybilLog.info(SybilLog.app, "Suppressing selection refresh transport interruption while app is inactive")
|
|
||||||
} else if selectedItem == target, draftKind == nil {
|
|
||||||
errorMessage = normalizeAPIError(error)
|
errorMessage = normalizeAPIError(error)
|
||||||
SybilLog.error(SybilLog.app, "Selection refresh failed", error: error)
|
SybilLog.error(SybilLog.app, "Selection refresh failed", error: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if selectedItem == target, draftKind == nil {
|
|
||||||
isLoadingSelection = false
|
isLoadingSelection = false
|
||||||
selectionTask = nil
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedChat = nil
|
selectedChat = nil
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
isLoadingSelection = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendChat(content: String, attachments: [ChatAttachment]) async throws {
|
private func sendChat(content: String, attachments: [ChatAttachment]) async throws {
|
||||||
@@ -1043,7 +877,7 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
pendingChatState = PendingChatState(
|
pendingChatState = PendingChatState(
|
||||||
chatID: currentChatID,
|
chatID: currentChatID,
|
||||||
messages: (currentSelectedChat?.messages ?? []) + [optimisticUser, optimisticAssistant]
|
messages: (selectedChat?.messages ?? []) + [optimisticUser, optimisticAssistant]
|
||||||
)
|
)
|
||||||
|
|
||||||
let client = try client()
|
let client = try client()
|
||||||
@@ -1052,16 +886,12 @@ final class SybilViewModel {
|
|||||||
if chatID == nil {
|
if chatID == nil {
|
||||||
let created = try await client.createChat(title: nil)
|
let created = try await client.createChat(title: nil)
|
||||||
chatID = created.id
|
chatID = created.id
|
||||||
let shouldShowCreatedChat = activeSendContext.map { isSendContextVisible($0) } ?? true
|
draftKind = nil
|
||||||
activeSendContext = .chat(created.id)
|
selectedItem = .chat(created.id)
|
||||||
|
|
||||||
chats.removeAll(where: { $0.id == created.id })
|
chats.removeAll(where: { $0.id == created.id })
|
||||||
chats.insert(created, at: 0)
|
chats.insert(created, at: 0)
|
||||||
|
|
||||||
if shouldShowCreatedChat {
|
|
||||||
draftKind = nil
|
|
||||||
selectedItem = .chat(created.id)
|
|
||||||
|
|
||||||
selectedChat = ChatDetail(
|
selectedChat = ChatDetail(
|
||||||
id: created.id,
|
id: created.id,
|
||||||
title: created.title,
|
title: created.title,
|
||||||
@@ -1074,7 +904,6 @@ final class SybilViewModel {
|
|||||||
messages: []
|
messages: []
|
||||||
)
|
)
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
}
|
|
||||||
|
|
||||||
SybilLog.info(SybilLog.app, "Created chat \(created.id)")
|
SybilLog.info(SybilLog.app, "Created chat \(created.id)")
|
||||||
}
|
}
|
||||||
@@ -1086,7 +915,7 @@ final class SybilViewModel {
|
|||||||
pendingChatState?.chatID = chatID
|
pendingChatState?.chatID = chatID
|
||||||
|
|
||||||
let baseChat: ChatDetail
|
let baseChat: ChatDetail
|
||||||
if let selectedChat = currentSelectedChat, selectedChat.id == chatID {
|
if let selectedChat, selectedChat.id == chatID {
|
||||||
baseChat = selectedChat
|
baseChat = selectedChat
|
||||||
} else {
|
} else {
|
||||||
baseChat = try await client.getChat(chatID: chatID)
|
baseChat = try await client.getChat(chatID: chatID)
|
||||||
@@ -1105,10 +934,8 @@ 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: currentSelectedChat) {
|
if isUntitledChat(chatID: chatID, detail: selectedChat) {
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
do {
|
do {
|
||||||
@@ -1142,7 +969,6 @@ final class SybilViewModel {
|
|||||||
chatBackgroundTask = nil
|
chatBackgroundTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
|
||||||
try await client.runCompletionStream(
|
try await client.runCompletionStream(
|
||||||
body: CompletionStreamRequest(
|
body: CompletionStreamRequest(
|
||||||
chatId: chatID,
|
chatId: chatID,
|
||||||
@@ -1154,67 +980,13 @@ final class SybilViewModel {
|
|||||||
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 {
|
await refreshCollections(preferredSelection: .chat(chatID))
|
||||||
pendingChatState = nil
|
selectedChat = try await client.getChat(chatID: chatID)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let sentChatSelection = SidebarSelection.chat(chatID)
|
|
||||||
let shouldKeepSentChatSelected = selectedItem == sentChatSelection && draftKind == nil
|
|
||||||
await refreshCollections(
|
|
||||||
preferredSelection: shouldKeepSentChatSelected ? sentChatSelection : selectedItem,
|
|
||||||
refreshSelection: false
|
|
||||||
)
|
|
||||||
|
|
||||||
guard selectedItem == sentChatSelection, draftKind == nil else {
|
|
||||||
pendingChatState = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
let refreshedChat = try await client.getChat(chatID: chatID)
|
|
||||||
guard selectedItem == sentChatSelection, draftKind == nil else {
|
|
||||||
pendingChatState = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
selectedChat = refreshedChat
|
|
||||||
} 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1252,17 +1024,12 @@ final class SybilViewModel {
|
|||||||
if searchID == nil {
|
if searchID == nil {
|
||||||
let created = try await client.createSearch(title: String(query.prefix(80)), query: query)
|
let created = try await client.createSearch(title: String(query.prefix(80)), query: query)
|
||||||
searchID = created.id
|
searchID = created.id
|
||||||
let shouldShowCreatedSearch = activeSendContext.map { isSendContextVisible($0) } ?? true
|
draftKind = nil
|
||||||
activeSendContext = .search(created.id)
|
selectedItem = .search(created.id)
|
||||||
|
|
||||||
searches.removeAll(where: { $0.id == created.id })
|
searches.removeAll(where: { $0.id == created.id })
|
||||||
searches.insert(created, at: 0)
|
searches.insert(created, at: 0)
|
||||||
|
|
||||||
if shouldShowCreatedSearch {
|
|
||||||
draftKind = nil
|
|
||||||
selectedItem = .search(created.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
SybilLog.info(SybilLog.app, "Created search \(created.id)")
|
SybilLog.info(SybilLog.app, "Created search \(created.id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1271,12 +1038,11 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if selectedItem == .search(searchID), draftKind == nil {
|
|
||||||
selectedSearch = SearchDetail(
|
selectedSearch = SearchDetail(
|
||||||
id: searchID,
|
id: searchID,
|
||||||
title: String(query.prefix(80)),
|
title: String(query.prefix(80)),
|
||||||
query: query,
|
query: query,
|
||||||
createdAt: currentSelectedSearch?.createdAt ?? now,
|
createdAt: selectedSearch?.createdAt ?? now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
requestId: nil,
|
requestId: nil,
|
||||||
latencyMs: nil,
|
latencyMs: nil,
|
||||||
@@ -1287,13 +1053,9 @@ final class SybilViewModel {
|
|||||||
answerError: nil,
|
answerError: nil,
|
||||||
results: []
|
results: []
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -1301,36 +1063,12 @@ final class SybilViewModel {
|
|||||||
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 {
|
await refreshCollections(preferredSelection: .search(searchID))
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let sentSearchSelection = SidebarSelection.search(searchID)
|
|
||||||
let shouldKeepSentSearchSelected = selectedItem == sentSearchSelection && draftKind == nil
|
|
||||||
await refreshCollections(
|
|
||||||
preferredSelection: shouldKeepSentSearchSelected ? sentSearchSelection : selectedItem,
|
|
||||||
refreshSelection: false
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applySearchEvent(
|
private func applySearchEvent(
|
||||||
@@ -1338,10 +1076,8 @@ final class SybilViewModel {
|
|||||||
searchID: String,
|
searchID: String,
|
||||||
streamStatus: SearchStreamStatus
|
streamStatus: SearchStreamStatus
|
||||||
) async {
|
) async {
|
||||||
guard let current = currentSelectedSearch, current.id == searchID else {
|
guard let current = selectedSearch, current.id == searchID else {
|
||||||
if case let .done(payload) = event,
|
if case let .done(payload) = event {
|
||||||
selectedItem == .search(searchID),
|
|
||||||
draftKind == nil {
|
|
||||||
selectedSearch = payload.search
|
selectedSearch = payload.search
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -1448,22 +1184,6 @@ final class SybilViewModel {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private var currentSelectedChat: ChatDetail? {
|
|
||||||
guard case let .chat(chatID) = selectedItem,
|
|
||||||
selectedChat?.id == chatID else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return selectedChat
|
|
||||||
}
|
|
||||||
|
|
||||||
private var currentSelectedSearch: SearchDetail? {
|
|
||||||
guard case let .search(searchID) = selectedItem,
|
|
||||||
selectedSearch?.id == searchID else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return selectedSearch
|
|
||||||
}
|
|
||||||
|
|
||||||
private var currentSearchID: String? {
|
private var currentSearchID: String? {
|
||||||
if draftKind == .search {
|
if draftKind == .search {
|
||||||
return nil
|
return nil
|
||||||
@@ -1474,33 +1194,6 @@ final class SybilViewModel {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private var currentSendContext: ActiveSendContext {
|
|
||||||
if isSearchMode {
|
|
||||||
if let searchID = currentSearchID {
|
|
||||||
return .search(searchID)
|
|
||||||
}
|
|
||||||
return .draftSearch(draftIdentity)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let chatID = currentChatID {
|
|
||||||
return .chat(chatID)
|
|
||||||
}
|
|
||||||
return .draftChat(draftIdentity)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isSendContextVisible(_ context: ActiveSendContext) -> Bool {
|
|
||||||
switch context {
|
|
||||||
case let .draftChat(identity):
|
|
||||||
return draftKind == .chat && draftIdentity == identity
|
|
||||||
case let .chat(chatID):
|
|
||||||
return selectedItem == .chat(chatID)
|
|
||||||
case let .draftSearch(identity):
|
|
||||||
return draftKind == .search && draftIdentity == identity
|
|
||||||
case let .search(searchID):
|
|
||||||
return selectedItem == .search(searchID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func hasSelection(_ selection: SidebarSelection, chats: [ChatSummary], searches: [SearchSummary]) -> Bool {
|
private func hasSelection(_ selection: SidebarSelection, chats: [ChatSummary], searches: [SearchSummary]) -> Bool {
|
||||||
switch selection {
|
switch selection {
|
||||||
case let .chat(chatID):
|
case let .chat(chatID):
|
||||||
@@ -1609,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
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -17,52 +17,25 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
private let searchesResponse: [SearchSummary]
|
private let searchesResponse: [SearchSummary]
|
||||||
private let chatDetails: [String: ChatDetail]
|
private let chatDetails: [String: ChatDetail]
|
||||||
private let searchDetails: [String: SearchDetail]
|
private let searchDetails: [String: SearchDetail]
|
||||||
private let createChatResponse: ChatSummary?
|
|
||||||
|
|
||||||
private var snapshot = MockClientCallSnapshot()
|
private var snapshot = MockClientCallSnapshot()
|
||||||
private var getChatDelayNanoseconds: UInt64 = 0
|
|
||||||
private var getSearchDelayNanoseconds: UInt64 = 0
|
|
||||||
private var completionStreamNetworkErrorMessage: String?
|
|
||||||
private var completionStreamDelayNanoseconds: UInt64 = 0
|
|
||||||
private var searchStreamNetworkErrorMessage: String?
|
|
||||||
private var searchStreamDelayNanoseconds: UInt64 = 0
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
chatsResponse: [ChatSummary] = [],
|
chatsResponse: [ChatSummary] = [],
|
||||||
searchesResponse: [SearchSummary] = [],
|
searchesResponse: [SearchSummary] = [],
|
||||||
chatDetails: [String: ChatDetail] = [:],
|
chatDetails: [String: ChatDetail] = [:],
|
||||||
searchDetails: [String: SearchDetail] = [:],
|
searchDetails: [String: SearchDetail] = [:]
|
||||||
createChatResponse: ChatSummary? = nil
|
|
||||||
) {
|
) {
|
||||||
self.chatsResponse = chatsResponse
|
self.chatsResponse = chatsResponse
|
||||||
self.searchesResponse = searchesResponse
|
self.searchesResponse = searchesResponse
|
||||||
self.chatDetails = chatDetails
|
self.chatDetails = chatDetails
|
||||||
self.searchDetails = searchDetails
|
self.searchDetails = searchDetails
|
||||||
self.createChatResponse = createChatResponse
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func currentSnapshot() -> MockClientCallSnapshot {
|
func currentSnapshot() -> MockClientCallSnapshot {
|
||||||
snapshot
|
snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
|
|
||||||
completionStreamNetworkErrorMessage = message
|
|
||||||
completionStreamDelayNanoseconds = delayNanoseconds
|
|
||||||
}
|
|
||||||
|
|
||||||
func setGetChatDelay(_ delayNanoseconds: UInt64) {
|
|
||||||
getChatDelayNanoseconds = delayNanoseconds
|
|
||||||
}
|
|
||||||
|
|
||||||
func setGetSearchDelay(_ delayNanoseconds: UInt64) {
|
|
||||||
getSearchDelayNanoseconds = 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")
|
||||||
}
|
}
|
||||||
@@ -73,17 +46,11 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func createChat(title: String?) async throws -> ChatSummary {
|
func createChat(title: String?) async throws -> ChatSummary {
|
||||||
if let createChatResponse {
|
|
||||||
return createChatResponse
|
|
||||||
}
|
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
func getChat(chatID: String) async throws -> ChatDetail {
|
func getChat(chatID: String) async throws -> ChatDetail {
|
||||||
snapshot.getChat += 1
|
snapshot.getChat += 1
|
||||||
if getChatDelayNanoseconds > 0 {
|
|
||||||
try await Task.sleep(nanoseconds: getChatDelayNanoseconds)
|
|
||||||
}
|
|
||||||
guard let detail = chatDetails[chatID] else {
|
guard let detail = chatDetails[chatID] else {
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
@@ -109,9 +76,6 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
|
|
||||||
func getSearch(searchID: String) async throws -> SearchDetail {
|
func getSearch(searchID: String) async throws -> SearchDetail {
|
||||||
snapshot.getSearch += 1
|
snapshot.getSearch += 1
|
||||||
if getSearchDelayNanoseconds > 0 {
|
|
||||||
try await Task.sleep(nanoseconds: getSearchDelayNanoseconds)
|
|
||||||
}
|
|
||||||
guard let detail = searchDetails[searchID] else {
|
guard let detail = searchDetails[searchID] else {
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
@@ -134,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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,178 +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 selectingChatClearsStaleTranscriptUntilNewDetailLoads() async throws {
|
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_210)
|
|
||||||
let staleDetail = makeChatDetail(id: "chat-old", date: date, body: "stale transcript")
|
|
||||||
let freshDetail = makeChatDetail(id: "chat-new", date: date, body: "fresh transcript")
|
|
||||||
let client = MockSybilClient(chatDetails: ["chat-new": freshDetail])
|
|
||||||
await client.setGetChatDelay(50_000_000)
|
|
||||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
|
||||||
viewModel.isAuthenticated = true
|
|
||||||
viewModel.isCheckingSession = false
|
|
||||||
viewModel.selectedItem = .chat("chat-old")
|
|
||||||
viewModel.selectedChat = staleDetail
|
|
||||||
|
|
||||||
viewModel.select(.chat("chat-new"))
|
|
||||||
|
|
||||||
#expect(viewModel.displayedMessages.isEmpty)
|
|
||||||
#expect(viewModel.isLoadingSelection)
|
|
||||||
|
|
||||||
try await Task.sleep(nanoseconds: 90_000_000)
|
|
||||||
|
|
||||||
#expect(viewModel.displayedMessages.first?.content == "fresh transcript")
|
|
||||||
#expect(!viewModel.isLoadingSelection)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func navigationSelectionWaitsForFastTranscriptLoad() async throws {
|
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_220)
|
|
||||||
let detail = makeChatDetail(id: "chat-fast", date: date, body: "loaded before push")
|
|
||||||
let client = MockSybilClient(chatDetails: ["chat-fast": detail])
|
|
||||||
await client.setGetChatDelay(20_000_000)
|
|
||||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
|
||||||
viewModel.isAuthenticated = true
|
|
||||||
viewModel.isCheckingSession = false
|
|
||||||
|
|
||||||
await viewModel.selectForNavigation(.chat("chat-fast"), preloadTimeout: .milliseconds(500))
|
|
||||||
|
|
||||||
#expect(viewModel.selectedItem == .chat("chat-fast"))
|
|
||||||
#expect(viewModel.displayedMessages.first?.content == "loaded before push")
|
|
||||||
#expect(!viewModel.isLoadingSelection)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func navigationSelectionTimesOutAndKeepsLoadingTranscript() async throws {
|
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_230)
|
|
||||||
let detail = makeChatDetail(id: "chat-slow", date: date, body: "loaded after push")
|
|
||||||
let client = MockSybilClient(chatDetails: ["chat-slow": detail])
|
|
||||||
await client.setGetChatDelay(100_000_000)
|
|
||||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
|
||||||
viewModel.isAuthenticated = true
|
|
||||||
viewModel.isCheckingSession = false
|
|
||||||
|
|
||||||
await viewModel.selectForNavigation(.chat("chat-slow"), preloadTimeout: .milliseconds(10))
|
|
||||||
|
|
||||||
#expect(viewModel.selectedItem == .chat("chat-slow"))
|
|
||||||
#expect(viewModel.displayedMessages.isEmpty)
|
|
||||||
#expect(viewModel.isLoadingSelection)
|
|
||||||
|
|
||||||
try await Task.sleep(nanoseconds: 150_000_000)
|
|
||||||
|
|
||||||
#expect(viewModel.displayedMessages.first?.content == "loaded after push")
|
|
||||||
#expect(!viewModel.isLoadingSelection)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func newDraftChatDoesNotShowTypingStateFromPreviousSend() async throws {
|
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_240)
|
|
||||||
let detail = makeChatDetail(id: "chat-typing", date: date, body: "existing transcript")
|
|
||||||
let client = MockSybilClient(chatDetails: ["chat-typing": detail])
|
|
||||||
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-typing")
|
|
||||||
viewModel.selectedChat = detail
|
|
||||||
viewModel.composer = "continue"
|
|
||||||
|
|
||||||
let sendTask = Task {
|
|
||||||
await viewModel.sendComposer()
|
|
||||||
}
|
|
||||||
try await Task.sleep(nanoseconds: 10_000_000)
|
|
||||||
|
|
||||||
#expect(viewModel.isSendingVisibleChat)
|
|
||||||
|
|
||||||
viewModel.startNewChat()
|
|
||||||
|
|
||||||
#expect(viewModel.displayedMessages.isEmpty)
|
|
||||||
#expect(!viewModel.isSendingVisibleChat)
|
|
||||||
|
|
||||||
await sendTask.value
|
|
||||||
}
|
|
||||||
|
|
||||||
@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)
|
||||||
@@ -503,14 +283,4 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
#expect(NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 2, verticalTravel: 1, leftwardVelocity: 120, verticalVelocity: 30))
|
#expect(NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 2, verticalTravel: 1, leftwardVelocity: 120, verticalVelocity: 30))
|
||||||
#expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 8, verticalTravel: 24, leftwardVelocity: 20, verticalVelocity: 140))
|
#expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 8, verticalTravel: 24, leftwardVelocity: 20, verticalVelocity: 140))
|
||||||
#expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 18, verticalTravel: 18, leftwardVelocity: 80, verticalVelocity: 90))
|
#expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 18, verticalTravel: 18, leftwardVelocity: 80, verticalVelocity: 90))
|
||||||
#expect(!NewChatSwipeMetrics.shouldComplete(offset: -24, velocityX: 0, width: width, isLatched: false))
|
|
||||||
#expect(NewChatSwipeMetrics.shouldComplete(offset: -24, velocityX: -800, width: width, isLatched: false))
|
|
||||||
#expect(!NewChatSwipeMetrics.shouldComplete(offset: -(latchDistance + 1), velocityX: 800, width: width, isLatched: true))
|
|
||||||
#expect(BackSwipeMetrics.clampedOffset(for: 500, width: width) == maxTravel)
|
|
||||||
#expect(BackSwipeMetrics.progress(for: maxTravel / 2, width: width) == 0.5)
|
|
||||||
#expect(BackSwipeMetrics.isLatched(offset: latchDistance + 1, width: width))
|
|
||||||
#expect(BackSwipeMetrics.shouldBeginPan(rightwardTravel: 24, verticalTravel: 8, rightwardVelocity: 0, verticalVelocity: 0))
|
|
||||||
#expect(!BackSwipeMetrics.shouldBeginPan(rightwardTravel: 8, verticalTravel: 24, rightwardVelocity: 20, verticalVelocity: 140))
|
|
||||||
#expect(BackSwipeMetrics.shouldComplete(offset: 24, velocityX: 800, width: width, isLatched: false))
|
|
||||||
#expect(!BackSwipeMetrics.shouldComplete(offset: latchDistance + 1, velocityX: -800, width: width, isLatched: true))
|
|
||||||
}
|
}
|
||||||
|
|||||||
22
ios/justfile
22
ios/justfile
@@ -1,28 +1,10 @@
|
|||||||
simulator := "platform=iOS Simulator,name=iPhone 16e,OS=latest"
|
|
||||||
simulator_name := "iPhone 16e"
|
|
||||||
derived_data := "build/DerivedData"
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@just build
|
@just build
|
||||||
|
|
||||||
build:
|
build:
|
||||||
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
|
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
|
||||||
if command -v xcbeautify >/dev/null 2>&1; then \
|
if command -v xcbeautify >/dev/null 2>&1; then \
|
||||||
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
|
xcodebuild -scheme Sybil -destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' | xcbeautify; \
|
||||||
else \
|
else \
|
||||||
xcodebuild -scheme Sybil -destination '{{simulator}}'; \
|
xcodebuild -scheme Sybil -destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest'; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
test:
|
|
||||||
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -parallel-testing-enabled NO
|
|
||||||
|
|
||||||
run:
|
|
||||||
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
|
|
||||||
xcrun simctl boot '{{simulator_name}}' 2>/dev/null || true
|
|
||||||
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}'
|
|
||||||
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
|
|
||||||
xcrun simctl launch booted net.buzzert.sybil2
|
|
||||||
|
|
||||||
screenshot path="build/sybil-screenshot.png":
|
|
||||||
mkdir -p "$(dirname '{{path}}')"
|
|
||||||
xcrun simctl io booted screenshot '{{path}}'
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
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,8 +1989,7 @@ 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>
|
</div>
|
||||||
@@ -2000,11 +1998,6 @@ export default function App() {
|
|||||||
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
|
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<SybilCharacter
|
|
||||||
className="absolute right-4 top-3 h-[calc(100%-1.5rem)]"
|
|
||||||
isBusy={isSending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 px-3 pb-3">
|
<div className="space-y-3 px-3 pb-3">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,14 +4,6 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "StalinistOne";
|
|
||||||
src: url("/StalinistOne-Regular.ttf") format("truetype");
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--background: 235 45% 4%;
|
--background: 235 45% 4%;
|
||||||
@@ -65,8 +57,8 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sybil-wordmark {
|
.sybil-wordmark {
|
||||||
font-family: "StalinistOne", "Orbitron", "Inter", sans-serif;
|
font-family: "Orbitron", "Inter", sans-serif;
|
||||||
font-weight: 400;
|
font-weight: 900;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
@@ -192,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