Compare commits
16 Commits
ios-pull-t
...
e6cf344527
| Author | SHA1 | Date | |
|---|---|---|---|
| e6cf344527 | |||
| 4bc0773d35 | |||
| d1140d21d4 | |||
| 0c0226e37e | |||
| 0b94d5b3fa | |||
| aff2531bf3 | |||
| ee8a93a8c4 | |||
| 53a3b722ec | |||
| ae783020ef | |||
| 39acefb55a | |||
| e6fe63280a | |||
| 2403dd99ae | |||
| 89bd418566 | |||
| e02168854c | |||
| 3820007289 | |||
| 5d046ca173 |
@@ -8,8 +8,19 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
|
||||
- `just build` will:
|
||||
1. generate `Sybil.xcodeproj` with `xcodegen` if missing,
|
||||
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.
|
||||
|
||||
## 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 target entry: `/Users/buzzert/src/sybil-2/ios/Apps/Sybil/Sources/SybilApp.swift`
|
||||
- Shared iOS app code lives in Swift package:
|
||||
|
||||
BIN
ios/Apps/Sybil/Resources/Character/character-busy.gif
Normal file
BIN
ios/Apps/Sybil/Resources/Character/character-busy.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
BIN
ios/Apps/Sybil/Resources/Character/character-idle.gif
Normal file
BIN
ios/Apps/Sybil/Resources/Character/character-idle.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
ios/Apps/Sybil/Resources/Fonts/StalinistOne-Regular.ttf
Normal file
BIN
ios/Apps/Sybil/Resources/Fonts/StalinistOne-Regular.ttf
Normal file
Binary file not shown.
@@ -6,6 +6,7 @@ public struct SplitView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var shouldRefreshOnForeground = false
|
||||
@State private var composerFocusRequest = 0
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
|
||||
|
||||
private var keyboardActions: SybilKeyboardActions? {
|
||||
guard !viewModel.isCheckingSession, viewModel.isAuthenticated else {
|
||||
@@ -50,16 +51,24 @@ public struct SplitView: View {
|
||||
} else if horizontalSizeClass == .compact {
|
||||
SybilPhoneShellView(viewModel: viewModel)
|
||||
} else {
|
||||
NavigationSplitView {
|
||||
SybilSidebarView(viewModel: viewModel)
|
||||
} detail: {
|
||||
SybilWorkspaceView(viewModel: viewModel, composerFocusRequest: composerFocusRequest) {
|
||||
viewModel.startNewChat()
|
||||
composerFocusRequest += 1
|
||||
GeometryReader { proxy in
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
SybilSidebarView(viewModel: viewModel)
|
||||
} detail: {
|
||||
SybilWorkspaceView(
|
||||
viewModel: viewModel,
|
||||
composerFocusRequest: composerFocusRequest,
|
||||
navigationLeadingControl: splitNavigationLeadingControl(for: proxy.size),
|
||||
onShowSidebar: showSidebar,
|
||||
onRequestNewChat: {
|
||||
viewModel.startNewChat()
|
||||
composerFocusRequest += 1
|
||||
}
|
||||
)
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.tint(SybilTheme.primary)
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.tint(SybilTheme.primary)
|
||||
}
|
||||
}
|
||||
.font(.sybil(.body))
|
||||
@@ -72,24 +81,37 @@ public struct SplitView: View {
|
||||
switch nextPhase {
|
||||
case .background:
|
||||
shouldRefreshOnForeground = true
|
||||
viewModel.markAppInactiveForNetwork()
|
||||
case .active:
|
||||
viewModel.markAppActiveForNetwork()
|
||||
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
|
||||
return
|
||||
}
|
||||
shouldRefreshOnForeground = false
|
||||
Task {
|
||||
await viewModel.refreshVisibleContent(
|
||||
await viewModel.refreshAfterAppBecameActive(
|
||||
refreshCollections: true,
|
||||
refreshSelection: viewModel.hasRefreshableSelection
|
||||
)
|
||||
}
|
||||
case .inactive:
|
||||
break
|
||||
shouldRefreshOnForeground = true
|
||||
viewModel.markAppInactiveForNetwork()
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -5,7 +5,8 @@ struct SybilChatTranscriptView: View {
|
||||
var messages: [Message]
|
||||
var isLoading: Bool
|
||||
var isSending: Bool
|
||||
@State private var hasHandledInitialTranscriptScroll = false
|
||||
var topContentInset: CGFloat = 0
|
||||
var bottomContentInset: CGFloat = 0
|
||||
|
||||
private var hasPendingAssistant: Bool {
|
||||
messages.contains { message in
|
||||
@@ -14,65 +15,42 @@ struct SybilChatTranscriptView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 26) {
|
||||
if isLoading && messages.isEmpty {
|
||||
Text("Loading messages…")
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 26) {
|
||||
if isSending && !hasPendingAssistant {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(SybilTheme.textMuted)
|
||||
Text("Assistant is typing…")
|
||||
.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 {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(SybilTheme.textMuted)
|
||||
Text("Assistant is typing…")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.id("typing-indicator")
|
||||
}
|
||||
|
||||
Color.clear
|
||||
.frame(height: 2)
|
||||
.id("chat-bottom-anchor")
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
}
|
||||
|
||||
ForEach(messages.reversed()) { message in
|
||||
MessageBubble(message: message, isSending: isSending)
|
||||
.frame(maxWidth: .infinity)
|
||||
.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)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.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)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 18 + bottomContentInset)
|
||||
.padding(.bottom, 18 + topContentInset)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ extension Theme {
|
||||
.paragraph { configuration in
|
||||
configuration.label
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.relativeLineSpacing(.em(0.36))
|
||||
.relativeLineSpacing(.em(0.46))
|
||||
.markdownMargin(top: .zero, bottom: .em(0.82))
|
||||
}
|
||||
.blockquote { configuration in
|
||||
|
||||
@@ -34,7 +34,7 @@ struct SybilPhoneShellView: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
SybilWordmark(size: 18)
|
||||
SybilWordmark(size: 21)
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: PhoneRoute.self) { route in
|
||||
@@ -51,19 +51,22 @@ struct SybilPhoneShellView: View {
|
||||
switch nextPhase {
|
||||
case .background:
|
||||
shouldRefreshOnForeground = true
|
||||
viewModel.markAppInactiveForNetwork()
|
||||
case .active:
|
||||
viewModel.markAppActiveForNetwork()
|
||||
guard shouldRefreshOnForeground else {
|
||||
return
|
||||
}
|
||||
shouldRefreshOnForeground = false
|
||||
Task {
|
||||
await viewModel.refreshVisibleContent(
|
||||
await viewModel.refreshAfterAppBecameActive(
|
||||
refreshCollections: path.isEmpty,
|
||||
refreshSelection: !path.isEmpty && viewModel.hasRefreshableSelection
|
||||
)
|
||||
}
|
||||
case .inactive:
|
||||
break
|
||||
shouldRefreshOnForeground = true
|
||||
viewModel.markAppInactiveForNetwork()
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
@@ -74,6 +77,27 @@ struct SybilPhoneShellView: View {
|
||||
private struct SybilPhoneSidebarRoot: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
@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 {
|
||||
VStack(spacing: 0) {
|
||||
@@ -113,12 +137,21 @@ private struct SybilPhoneSidebarRoot: View {
|
||||
.padding(16)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(viewModel.sidebarItems) { item in
|
||||
NavigationLink(value: PhoneRoute.from(selection: item.selection)) {
|
||||
SybilPhoneSidebarRow(item: item)
|
||||
Button {
|
||||
open(item.selection)
|
||||
} label: {
|
||||
VStack(spacing: 0.0) {
|
||||
SybilPhoneSidebarRow(item: item)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(
|
||||
SybilPhoneSidebarRowButtonStyle(
|
||||
isHighlighted: highlightedSelection == item.selection
|
||||
)
|
||||
)
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
@@ -130,7 +163,6 @@ private struct SybilPhoneSidebarRoot: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,17 +179,20 @@ private struct SybilPhoneSidebarRoot: View {
|
||||
|
||||
HStack(spacing: 12) {
|
||||
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
|
||||
clearOpeningSelection()
|
||||
path = [.settings]
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
|
||||
clearOpeningSelection()
|
||||
viewModel.startNewSearch()
|
||||
path = [.draftSearch]
|
||||
}
|
||||
|
||||
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
|
||||
clearOpeningSelection()
|
||||
viewModel.startNewChat()
|
||||
path = [.draftChat]
|
||||
}
|
||||
@@ -195,25 +230,69 @@ private struct SybilPhoneSidebarRoot: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(accessibilityLabel)
|
||||
}
|
||||
|
||||
private func clearOpeningSelection() {
|
||||
openingRequestID = nil
|
||||
openingSelection = nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
path = [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 {
|
||||
@Environment(\.sybilPhoneSidebarRowIsActive) private var isHighlighted
|
||||
var item: SidebarItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
let leadingWidth = 22.0
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: item.kind == .chat ? "message" : "globe")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.frame(width: 22, height: 22)
|
||||
.foregroundStyle(isHighlighted ? SybilTheme.accent : SybilTheme.textMuted)
|
||||
.frame(width: leadingWidth, height: leadingWidth)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(SybilTheme.surface.opacity(0.72))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.stroke(SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||
)
|
||||
Rectangle()
|
||||
.fill(isHighlighted ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
|
||||
|
||||
)
|
||||
|
||||
Text(item.title)
|
||||
@@ -222,6 +301,9 @@ private struct SybilPhoneSidebarRow: View {
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Spacer()
|
||||
.frame(width: leadingWidth)
|
||||
|
||||
Text(item.updatedAt.sybilRelativeLabel)
|
||||
.font(.sybil(.caption2))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
@@ -238,17 +320,17 @@ private struct SybilPhoneSidebarRow: View {
|
||||
}
|
||||
}
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.padding(18.0)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(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)
|
||||
Rectangle()
|
||||
.fill(
|
||||
isHighlighted
|
||||
? SybilTheme.selectedRowGradient
|
||||
: LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
)
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,15 +341,19 @@ private struct SybilPhoneDestinationView: View {
|
||||
let route: PhoneRoute
|
||||
|
||||
var body: some View {
|
||||
SybilWorkspaceView(viewModel: viewModel, composerFocusRequest: composerFocusRequest) {
|
||||
viewModel.startNewChat()
|
||||
composerFocusRequest += 1
|
||||
if path.isEmpty {
|
||||
path = [.draftChat]
|
||||
} else {
|
||||
path[path.index(before: path.endIndex)] = .draftChat
|
||||
SybilWorkspaceView(
|
||||
viewModel: viewModel,
|
||||
composerFocusRequest: composerFocusRequest,
|
||||
onRequestNewChat: {
|
||||
viewModel.startNewChat()
|
||||
composerFocusRequest += 1
|
||||
if path.isEmpty {
|
||||
path = [.draftChat]
|
||||
} else {
|
||||
path[path.index(before: path.endIndex)] = .draftChat
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task(id: route) {
|
||||
@@ -278,8 +364,14 @@ private struct SybilPhoneDestinationView: View {
|
||||
private func applyRoute() {
|
||||
switch route {
|
||||
case let .chat(chatID):
|
||||
guard viewModel.draftKind != nil || viewModel.selectedItem != .chat(chatID) else {
|
||||
return
|
||||
}
|
||||
viewModel.select(.chat(chatID))
|
||||
case let .search(searchID):
|
||||
guard viewModel.draftKind != nil || viewModel.selectedItem != .search(searchID) else {
|
||||
return
|
||||
}
|
||||
viewModel.select(.search(searchID))
|
||||
case .draftChat:
|
||||
viewModel.startNewChat()
|
||||
|
||||
@@ -6,6 +6,8 @@ struct SybilSearchResultsView: View {
|
||||
var isLoading: Bool
|
||||
var isRunning: Bool
|
||||
var isStartingChat: Bool = false
|
||||
var topContentInset: CGFloat = 0
|
||||
var bottomContentInset: CGFloat = 0
|
||||
var onStartChat: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
@@ -98,7 +100,8 @@ struct SybilSearchResultsView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 20)
|
||||
.padding(.top, 20 + topContentInset)
|
||||
.padding(.bottom, 20 + bottomContentInset)
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -9,7 +9,7 @@ enum SybilFontRegistry {
|
||||
}
|
||||
|
||||
private static let registeredFonts: Void = {
|
||||
for fontName in ["Inter", "Orbitron"] {
|
||||
for fontName in ["Inter", "Orbitron", "StalinistOne-Regular"] {
|
||||
guard let url = Bundle.main.url(forResource: fontName, withExtension: "ttf", subdirectory: "Fonts") ??
|
||||
Bundle.main.url(forResource: fontName, withExtension: "ttf")
|
||||
else {
|
||||
@@ -203,7 +203,7 @@ struct SybilWordmark: View {
|
||||
|
||||
var body: some View {
|
||||
Text("SYBIL")
|
||||
.font(.custom("Orbitron", size: size))
|
||||
.font(.custom("Stalinist One", size: size))
|
||||
.fontWeight(.black)
|
||||
.tracking(0)
|
||||
.foregroundStyle(SybilTheme.brandGradient)
|
||||
|
||||
@@ -42,6 +42,22 @@ private struct PendingChatState {
|
||||
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 var streamError: String?
|
||||
|
||||
@@ -99,11 +115,17 @@ final class SybilViewModel {
|
||||
@ObservationIgnored
|
||||
private var hasBootstrapped = false
|
||||
private var pendingChatState: PendingChatState?
|
||||
private var activeSendContext: ActiveSendContext?
|
||||
private var draftIdentity = UUID()
|
||||
@ObservationIgnored
|
||||
private var selectionTask: Task<Void, Never>?
|
||||
@ObservationIgnored
|
||||
private var chatBackgroundTask: SybilBackgroundTaskAssertion?
|
||||
@ObservationIgnored
|
||||
private var isAppActive = true
|
||||
@ObservationIgnored
|
||||
private var appLifecycleGeneration = 0
|
||||
@ObservationIgnored
|
||||
private let clientFactory: (APIConfiguration) -> any SybilAPIClienting
|
||||
|
||||
private let fallbackModels: [Provider: [String]] = [
|
||||
@@ -153,7 +175,7 @@ final class SybilViewModel {
|
||||
|
||||
switch selectedItem {
|
||||
case .chat:
|
||||
if let selectedChat {
|
||||
if let selectedChat = currentSelectedChat {
|
||||
return chatTitle(title: selectedChat.title, messages: selectedChat.messages)
|
||||
}
|
||||
if let summary = selectedChatSummary {
|
||||
@@ -162,7 +184,7 @@ final class SybilViewModel {
|
||||
return "Chat"
|
||||
|
||||
case .search:
|
||||
if let selectedSearch {
|
||||
if let selectedSearch = currentSelectedSearch {
|
||||
return searchTitle(title: selectedSearch.title, query: selectedSearch.query)
|
||||
}
|
||||
if let summary = selectedSearchSummary {
|
||||
@@ -229,7 +251,7 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
var displayedMessages: [Message] {
|
||||
let canonical = displayableMessages(selectedChat?.messages ?? [])
|
||||
let canonical = displayableMessages(currentSelectedChat?.messages ?? [])
|
||||
guard let pending = pendingChatState else {
|
||||
return canonical
|
||||
}
|
||||
@@ -248,6 +270,40 @@ final class SybilViewModel {
|
||||
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] {
|
||||
let chatItems: [SidebarItem] = chats.map { chat in
|
||||
let initiatedLabel: String?
|
||||
@@ -276,7 +332,7 @@ final class SybilViewModel {
|
||||
kind: .search,
|
||||
title: searchTitle(title: search.title, query: search.query),
|
||||
updatedAt: search.updatedAt,
|
||||
initiatedLabel: nil
|
||||
initiatedLabel: "exa"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -320,7 +376,10 @@ final class SybilViewModel {
|
||||
isCheckingSession = true
|
||||
authError = nil
|
||||
errorMessage = nil
|
||||
resetSelectionLoading()
|
||||
pendingChatState = nil
|
||||
activeSendContext = nil
|
||||
draftIdentity = UUID()
|
||||
composerAttachments = []
|
||||
settings.persist()
|
||||
|
||||
@@ -392,10 +451,13 @@ final class SybilViewModel {
|
||||
|
||||
func startNewChat() {
|
||||
SybilLog.debug(SybilLog.ui, "Starting draft chat")
|
||||
resetSelectionLoading()
|
||||
draftIdentity = UUID()
|
||||
draftKind = .chat
|
||||
selectedItem = nil
|
||||
selectedChat = nil
|
||||
selectedSearch = nil
|
||||
pendingChatState = nil
|
||||
errorMessage = nil
|
||||
composer = ""
|
||||
composerAttachments = []
|
||||
@@ -403,10 +465,13 @@ final class SybilViewModel {
|
||||
|
||||
func startNewSearch() {
|
||||
SybilLog.debug(SybilLog.ui, "Starting draft search")
|
||||
resetSelectionLoading()
|
||||
draftIdentity = UUID()
|
||||
draftKind = .search
|
||||
selectedItem = nil
|
||||
selectedChat = nil
|
||||
selectedSearch = nil
|
||||
pendingChatState = nil
|
||||
errorMessage = nil
|
||||
composer = ""
|
||||
composerAttachments = []
|
||||
@@ -414,33 +479,72 @@ final class SybilViewModel {
|
||||
|
||||
func openSettings() {
|
||||
SybilLog.debug(SybilLog.ui, "Opening settings")
|
||||
resetSelectionLoading()
|
||||
draftKind = nil
|
||||
selectedItem = .settings
|
||||
selectedChat = nil
|
||||
selectedSearch = nil
|
||||
pendingChatState = nil
|
||||
errorMessage = nil
|
||||
composerAttachments = []
|
||||
}
|
||||
|
||||
func select(_ selection: SidebarSelection) {
|
||||
SybilLog.debug(SybilLog.ui, "Selecting \(selection.id)")
|
||||
draftKind = nil
|
||||
selectedItem = selection
|
||||
errorMessage = nil
|
||||
if case .search = selection {
|
||||
composerAttachments = []
|
||||
}
|
||||
_ = beginSelecting(selection)
|
||||
}
|
||||
|
||||
if case .settings = selection {
|
||||
selectedChat = nil
|
||||
selectedSearch = nil
|
||||
func selectForNavigation(_ selection: SidebarSelection, preloadTimeout: Duration = .seconds(3)) async {
|
||||
guard beginSelecting(selection) != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
selectionTask?.cancel()
|
||||
selectionTask = Task { [weak self] in
|
||||
await self?.refreshSelectionIfNeeded()
|
||||
await waitForSelectionLoad(timeout: preloadTimeout)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func beginSelecting(_ selection: SidebarSelection) -> Task<Void, Never>? {
|
||||
SybilLog.debug(SybilLog.ui, "Selecting \(selection.id)")
|
||||
|
||||
if draftKind == nil, selectedItem == selection {
|
||||
errorMessage = nil
|
||||
if case .search = selection {
|
||||
composerAttachments = []
|
||||
}
|
||||
|
||||
if needsSelectionLoad(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
|
||||
selectedSearch = nil
|
||||
pendingChatState = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
return startSelectionRefreshTask()
|
||||
}
|
||||
|
||||
func selectPreviousSidebarItem() {
|
||||
@@ -502,6 +606,38 @@ final class SybilViewModel {
|
||||
await reconnect()
|
||||
}
|
||||
|
||||
func markAppInactiveForNetwork() {
|
||||
guard isAppActive else {
|
||||
return
|
||||
}
|
||||
|
||||
isAppActive = false
|
||||
appLifecycleGeneration += 1
|
||||
SybilLog.debug(SybilLog.app, "App became inactive for network lifecycle generation \(appLifecycleGeneration)")
|
||||
}
|
||||
|
||||
func markAppActiveForNetwork() {
|
||||
isAppActive = true
|
||||
}
|
||||
|
||||
func refreshAfterAppBecameActive(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
|
||||
markAppActiveForNetwork()
|
||||
|
||||
guard isAuthenticated, !isCheckingSession else {
|
||||
return
|
||||
}
|
||||
|
||||
guard shouldRefreshCollections || shouldRefreshSelection else {
|
||||
return
|
||||
}
|
||||
|
||||
try? await Task.sleep(for: .milliseconds(150))
|
||||
await refreshVisibleContent(
|
||||
refreshCollections: shouldRefreshCollections,
|
||||
refreshSelection: shouldRefreshSelection
|
||||
)
|
||||
}
|
||||
|
||||
func refreshVisibleContent(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
|
||||
guard isAuthenticated, !isCheckingSession else {
|
||||
return
|
||||
@@ -529,12 +665,13 @@ final class SybilViewModel {
|
||||
func sendComposer() async {
|
||||
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let attachments = composerAttachments
|
||||
let sendContext = currentSendContext
|
||||
|
||||
guard !isSending else {
|
||||
return
|
||||
}
|
||||
|
||||
if isSearchMode {
|
||||
if sendContext.isSearch {
|
||||
guard !content.isEmpty else { return }
|
||||
} else if content.isEmpty && attachments.isEmpty {
|
||||
return
|
||||
@@ -543,10 +680,11 @@ final class SybilViewModel {
|
||||
composer = ""
|
||||
composerAttachments = []
|
||||
errorMessage = nil
|
||||
activeSendContext = sendContext
|
||||
isSending = true
|
||||
|
||||
do {
|
||||
if isSearchMode {
|
||||
if sendContext.isSearch {
|
||||
SybilLog.info(SybilLog.ui, "Sending search query")
|
||||
try await sendSearch(query: content)
|
||||
} else {
|
||||
@@ -554,35 +692,43 @@ final class SybilViewModel {
|
||||
try await sendChat(content: content, attachments: attachments)
|
||||
}
|
||||
} catch {
|
||||
errorMessage = normalizeAPIError(error)
|
||||
let shouldSurfaceError = isSendContextVisible(sendContext) || (activeSendContext.map { isSendContextVisible($0) } ?? false)
|
||||
if shouldSurfaceError {
|
||||
errorMessage = normalizeAPIError(error)
|
||||
}
|
||||
SybilLog.error(SybilLog.ui, "Send failed", error: error)
|
||||
|
||||
if case let .chat(chatID) = selectedItem {
|
||||
if shouldSurfaceError, case let .chat(chatID) = selectedItem {
|
||||
do {
|
||||
let chat = try await client().getChat(chatID: chatID)
|
||||
selectedChat = chat
|
||||
if selectedItem == .chat(chatID), draftKind == nil {
|
||||
selectedChat = chat
|
||||
}
|
||||
} catch {
|
||||
SybilLog.error(SybilLog.ui, "Fallback chat refresh after failure failed", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
if case let .search(searchID) = selectedItem {
|
||||
if shouldSurfaceError, case let .search(searchID) = selectedItem {
|
||||
do {
|
||||
let search = try await client().getSearch(searchID: searchID)
|
||||
selectedSearch = search
|
||||
if selectedItem == .search(searchID), draftKind == nil {
|
||||
selectedSearch = search
|
||||
}
|
||||
} catch {
|
||||
SybilLog.error(SybilLog.ui, "Fallback search refresh after failure failed", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
if !isSearchMode {
|
||||
if !sendContext.isSearch, shouldSurfaceError {
|
||||
composer = content
|
||||
composerAttachments = attachments
|
||||
pendingChatState = nil
|
||||
}
|
||||
pendingChatState = nil
|
||||
}
|
||||
|
||||
isSending = false
|
||||
activeSendContext = nil
|
||||
}
|
||||
|
||||
func appendComposerAttachments(_ attachments: [ChatAttachment]) throws {
|
||||
@@ -608,16 +754,25 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
func startChatFromSelectedSearch() async {
|
||||
guard let search = selectedSearch, !isCreatingSearchChat, !isSending else {
|
||||
guard let search = currentSelectedSearch, !isCreatingSearchChat, !isSending else {
|
||||
return
|
||||
}
|
||||
|
||||
let sourceSelection = SidebarSelection.search(search.id)
|
||||
isCreatingSearchChat = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let client = try client()
|
||||
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
|
||||
pendingChatState = nil
|
||||
composer = ""
|
||||
@@ -729,6 +884,12 @@ final class SybilViewModel {
|
||||
SybilLog.app,
|
||||
"Refreshed collections: \(nextChats.count) chats, \(nextSearches.count) searches"
|
||||
)
|
||||
errorMessage = nil
|
||||
|
||||
if draftKind != nil {
|
||||
isLoadingCollections = false
|
||||
return
|
||||
}
|
||||
|
||||
if case .settings = selectedItem {
|
||||
isLoadingCollections = false
|
||||
@@ -749,33 +910,79 @@ final class SybilViewModel {
|
||||
await refreshSelectionIfNeeded()
|
||||
}
|
||||
} catch {
|
||||
errorMessage = normalizeAPIError(error)
|
||||
SybilLog.error(SybilLog.app, "Refresh collections failed", error: error)
|
||||
if shouldSuppressInactiveTransportError(error) {
|
||||
SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive")
|
||||
} else {
|
||||
errorMessage = normalizeAPIError(error)
|
||||
SybilLog.error(SybilLog.app, "Refresh collections failed", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
isLoadingCollections = false
|
||||
}
|
||||
|
||||
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 {
|
||||
guard let selectedItem else {
|
||||
guard let target = selectedItem else {
|
||||
selectedChat = nil
|
||||
selectedSearch = nil
|
||||
isLoadingSelection = false
|
||||
return
|
||||
}
|
||||
|
||||
guard case .settings = selectedItem else {
|
||||
guard case .settings = target else {
|
||||
isLoadingSelection = true
|
||||
do {
|
||||
let client = try client()
|
||||
switch selectedItem {
|
||||
switch target {
|
||||
case let .chat(chatID):
|
||||
SybilLog.debug(SybilLog.app, "Refreshing chat \(chatID)")
|
||||
selectedChat = try await client.getChat(chatID: chatID)
|
||||
let chat = try await client.getChat(chatID: chatID)
|
||||
guard selectedItem == target, draftKind == nil else {
|
||||
return
|
||||
}
|
||||
selectedChat = chat
|
||||
selectedSearch = nil
|
||||
|
||||
if let detail = selectedChat,
|
||||
let provider = detail.lastUsedProvider,
|
||||
let model = detail.lastUsedModel,
|
||||
if let provider = chat.lastUsedProvider,
|
||||
let model = chat.lastUsedModel,
|
||||
!model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
@@ -783,26 +990,37 @@ final class SybilViewModel {
|
||||
|
||||
case let .search(searchID):
|
||||
SybilLog.debug(SybilLog.app, "Refreshing search \(searchID)")
|
||||
selectedSearch = try await client.getSearch(searchID: searchID)
|
||||
let search = try await client.getSearch(searchID: searchID)
|
||||
guard selectedItem == target, draftKind == nil else {
|
||||
return
|
||||
}
|
||||
selectedSearch = search
|
||||
selectedChat = nil
|
||||
|
||||
case .settings:
|
||||
break
|
||||
}
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
if isCancellation(error) {
|
||||
SybilLog.debug(SybilLog.app, "Selection refresh cancelled for \(selectedItem.id)")
|
||||
} else {
|
||||
SybilLog.debug(SybilLog.app, "Selection refresh cancelled for \(target.id)")
|
||||
} else if shouldSuppressInactiveTransportError(error) {
|
||||
SybilLog.info(SybilLog.app, "Suppressing selection refresh transport interruption while app is inactive")
|
||||
} else if selectedItem == target, draftKind == nil {
|
||||
errorMessage = normalizeAPIError(error)
|
||||
SybilLog.error(SybilLog.app, "Selection refresh failed", error: error)
|
||||
}
|
||||
}
|
||||
isLoadingSelection = false
|
||||
if selectedItem == target, draftKind == nil {
|
||||
isLoadingSelection = false
|
||||
selectionTask = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
selectedChat = nil
|
||||
selectedSearch = nil
|
||||
isLoadingSelection = false
|
||||
}
|
||||
|
||||
private func sendChat(content: String, attachments: [ChatAttachment]) async throws {
|
||||
@@ -825,7 +1043,7 @@ final class SybilViewModel {
|
||||
|
||||
pendingChatState = PendingChatState(
|
||||
chatID: currentChatID,
|
||||
messages: (selectedChat?.messages ?? []) + [optimisticUser, optimisticAssistant]
|
||||
messages: (currentSelectedChat?.messages ?? []) + [optimisticUser, optimisticAssistant]
|
||||
)
|
||||
|
||||
let client = try client()
|
||||
@@ -834,24 +1052,29 @@ final class SybilViewModel {
|
||||
if chatID == nil {
|
||||
let created = try await client.createChat(title: nil)
|
||||
chatID = created.id
|
||||
draftKind = nil
|
||||
selectedItem = .chat(created.id)
|
||||
let shouldShowCreatedChat = activeSendContext.map { isSendContextVisible($0) } ?? true
|
||||
activeSendContext = .chat(created.id)
|
||||
|
||||
chats.removeAll(where: { $0.id == created.id })
|
||||
chats.insert(created, at: 0)
|
||||
|
||||
selectedChat = ChatDetail(
|
||||
id: created.id,
|
||||
title: created.title,
|
||||
createdAt: created.createdAt,
|
||||
updatedAt: created.updatedAt,
|
||||
initiatedProvider: created.initiatedProvider,
|
||||
initiatedModel: created.initiatedModel,
|
||||
lastUsedProvider: created.lastUsedProvider,
|
||||
lastUsedModel: created.lastUsedModel,
|
||||
messages: []
|
||||
)
|
||||
selectedSearch = nil
|
||||
if shouldShowCreatedChat {
|
||||
draftKind = nil
|
||||
selectedItem = .chat(created.id)
|
||||
|
||||
selectedChat = ChatDetail(
|
||||
id: created.id,
|
||||
title: created.title,
|
||||
createdAt: created.createdAt,
|
||||
updatedAt: created.updatedAt,
|
||||
initiatedProvider: created.initiatedProvider,
|
||||
initiatedModel: created.initiatedModel,
|
||||
lastUsedProvider: created.lastUsedProvider,
|
||||
lastUsedModel: created.lastUsedModel,
|
||||
messages: []
|
||||
)
|
||||
selectedSearch = nil
|
||||
}
|
||||
|
||||
SybilLog.info(SybilLog.app, "Created chat \(created.id)")
|
||||
}
|
||||
@@ -863,7 +1086,7 @@ final class SybilViewModel {
|
||||
pendingChatState?.chatID = chatID
|
||||
|
||||
let baseChat: ChatDetail
|
||||
if let selectedChat, selectedChat.id == chatID {
|
||||
if let selectedChat = currentSelectedChat, selectedChat.id == chatID {
|
||||
baseChat = selectedChat
|
||||
} else {
|
||||
baseChat = try await client.getChat(chatID: chatID)
|
||||
@@ -882,8 +1105,10 @@ final class SybilViewModel {
|
||||
} + [CompletionRequestMessage(role: .user, content: content, attachments: attachments.isEmpty ? nil : attachments)]
|
||||
|
||||
let streamStatus = CompletionStreamStatus()
|
||||
let streamLifecycleGeneration = appLifecycleGeneration
|
||||
let streamStartedWhileInactive = !isAppActive
|
||||
|
||||
if isUntitledChat(chatID: chatID, detail: selectedChat) {
|
||||
if isUntitledChat(chatID: chatID, detail: currentSelectedChat) {
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
@@ -917,24 +1142,79 @@ final class SybilViewModel {
|
||||
chatBackgroundTask = nil
|
||||
}
|
||||
|
||||
try await client.runCompletionStream(
|
||||
body: CompletionStreamRequest(
|
||||
chatId: chatID,
|
||||
provider: provider,
|
||||
model: selectedModel,
|
||||
messages: requestMessages
|
||||
)
|
||||
) { [weak self] event in
|
||||
guard let self else { return }
|
||||
await self.applyCompletionEvent(event, streamStatus: streamStatus)
|
||||
do {
|
||||
try await client.runCompletionStream(
|
||||
body: CompletionStreamRequest(
|
||||
chatId: chatID,
|
||||
provider: provider,
|
||||
model: selectedModel,
|
||||
messages: requestMessages
|
||||
)
|
||||
) { [weak self] event in
|
||||
guard let self else { return }
|
||||
await self.applyCompletionEvent(event, streamStatus: streamStatus)
|
||||
}
|
||||
} catch {
|
||||
if shouldSuppressLifecycleTransportError(
|
||||
error,
|
||||
startedAt: streamLifecycleGeneration,
|
||||
startedWhileInactive: streamStartedWhileInactive
|
||||
) {
|
||||
SybilLog.info(SybilLog.app, "Suppressing chat stream transport interruption after app lifecycle change")
|
||||
pendingChatState = nil
|
||||
if isAppActive {
|
||||
await refreshInterruptedStream(preferredSelection: .chat(chatID))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
if let streamError = await streamStatus.error() {
|
||||
throw APIError.httpError(statusCode: 502, message: streamError)
|
||||
}
|
||||
|
||||
await refreshCollections(preferredSelection: .chat(chatID))
|
||||
selectedChat = try await client.getChat(chatID: chatID)
|
||||
guard isAppActive else {
|
||||
pendingChatState = nil
|
||||
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
|
||||
}
|
||||
|
||||
@@ -972,12 +1252,17 @@ final class SybilViewModel {
|
||||
if searchID == nil {
|
||||
let created = try await client.createSearch(title: String(query.prefix(80)), query: query)
|
||||
searchID = created.id
|
||||
draftKind = nil
|
||||
selectedItem = .search(created.id)
|
||||
let shouldShowCreatedSearch = activeSendContext.map { isSendContextVisible($0) } ?? true
|
||||
activeSendContext = .search(created.id)
|
||||
|
||||
searches.removeAll(where: { $0.id == created.id })
|
||||
searches.insert(created, at: 0)
|
||||
|
||||
if shouldShowCreatedSearch {
|
||||
draftKind = nil
|
||||
selectedItem = .search(created.id)
|
||||
}
|
||||
|
||||
SybilLog.info(SybilLog.app, "Created search \(created.id)")
|
||||
}
|
||||
|
||||
@@ -986,37 +1271,66 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
let now = Date()
|
||||
selectedSearch = SearchDetail(
|
||||
id: searchID,
|
||||
title: String(query.prefix(80)),
|
||||
query: query,
|
||||
createdAt: selectedSearch?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
requestId: nil,
|
||||
latencyMs: nil,
|
||||
error: nil,
|
||||
answerText: nil,
|
||||
answerRequestId: nil,
|
||||
answerCitations: nil,
|
||||
answerError: nil,
|
||||
results: []
|
||||
)
|
||||
if selectedItem == .search(searchID), draftKind == nil {
|
||||
selectedSearch = SearchDetail(
|
||||
id: searchID,
|
||||
title: String(query.prefix(80)),
|
||||
query: query,
|
||||
createdAt: currentSelectedSearch?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
requestId: nil,
|
||||
latencyMs: nil,
|
||||
error: nil,
|
||||
answerText: nil,
|
||||
answerRequestId: nil,
|
||||
answerCitations: nil,
|
||||
answerError: nil,
|
||||
results: []
|
||||
)
|
||||
}
|
||||
|
||||
let streamStatus = SearchStreamStatus()
|
||||
let streamLifecycleGeneration = appLifecycleGeneration
|
||||
let streamStartedWhileInactive = !isAppActive
|
||||
|
||||
try await client.runSearchStream(
|
||||
searchID: searchID,
|
||||
body: SearchRunRequest(query: query, title: String(query.prefix(80)), type: "auto", numResults: 10)
|
||||
) { [weak self] event in
|
||||
guard let self else { return }
|
||||
await self.applySearchEvent(event, searchID: searchID, streamStatus: streamStatus)
|
||||
do {
|
||||
try await client.runSearchStream(
|
||||
searchID: searchID,
|
||||
body: SearchRunRequest(query: query, title: String(query.prefix(80)), type: "auto", numResults: 10)
|
||||
) { [weak self] event in
|
||||
guard let self else { return }
|
||||
await self.applySearchEvent(event, searchID: searchID, streamStatus: streamStatus)
|
||||
}
|
||||
} catch {
|
||||
if shouldSuppressLifecycleTransportError(
|
||||
error,
|
||||
startedAt: streamLifecycleGeneration,
|
||||
startedWhileInactive: streamStartedWhileInactive
|
||||
) {
|
||||
SybilLog.info(SybilLog.app, "Suppressing search stream transport interruption after app lifecycle change")
|
||||
if isAppActive {
|
||||
await refreshInterruptedStream(preferredSelection: .search(searchID))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
if let streamError = await streamStatus.error() {
|
||||
throw APIError.httpError(statusCode: 502, message: streamError)
|
||||
}
|
||||
|
||||
await refreshCollections(preferredSelection: .search(searchID))
|
||||
guard isAppActive else {
|
||||
return
|
||||
}
|
||||
|
||||
let sentSearchSelection = SidebarSelection.search(searchID)
|
||||
let shouldKeepSentSearchSelected = selectedItem == sentSearchSelection && draftKind == nil
|
||||
await refreshCollections(
|
||||
preferredSelection: shouldKeepSentSearchSelected ? sentSearchSelection : selectedItem,
|
||||
refreshSelection: false
|
||||
)
|
||||
}
|
||||
|
||||
private func applySearchEvent(
|
||||
@@ -1024,8 +1338,10 @@ final class SybilViewModel {
|
||||
searchID: String,
|
||||
streamStatus: SearchStreamStatus
|
||||
) async {
|
||||
guard let current = selectedSearch, current.id == searchID else {
|
||||
if case let .done(payload) = event {
|
||||
guard let current = currentSelectedSearch, current.id == searchID else {
|
||||
if case let .done(payload) = event,
|
||||
selectedItem == .search(searchID),
|
||||
draftKind == nil {
|
||||
selectedSearch = payload.search
|
||||
}
|
||||
return
|
||||
@@ -1132,6 +1448,22 @@ final class SybilViewModel {
|
||||
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? {
|
||||
if draftKind == .search {
|
||||
return nil
|
||||
@@ -1142,6 +1474,33 @@ final class SybilViewModel {
|
||||
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 {
|
||||
switch selection {
|
||||
case let .chat(chatID):
|
||||
@@ -1250,6 +1609,57 @@ final class SybilViewModel {
|
||||
#endif
|
||||
}
|
||||
|
||||
private func refreshInterruptedStream(preferredSelection: SidebarSelection) async {
|
||||
try? await Task.sleep(for: .milliseconds(150))
|
||||
await refreshCollections(preferredSelection: preferredSelection)
|
||||
}
|
||||
|
||||
private func shouldSuppressLifecycleTransportError(
|
||||
_ error: Error,
|
||||
startedAt generation: Int,
|
||||
startedWhileInactive: Bool
|
||||
) -> Bool {
|
||||
guard generation != appLifecycleGeneration || startedWhileInactive || !isAppActive else {
|
||||
return false
|
||||
}
|
||||
|
||||
return isTransientTransportInterruption(error)
|
||||
}
|
||||
|
||||
private func shouldSuppressInactiveTransportError(_ error: Error) -> Bool {
|
||||
!isAppActive && isTransientTransportInterruption(error)
|
||||
}
|
||||
|
||||
private func isTransientTransportInterruption(_ error: Error) -> Bool {
|
||||
if isCancellation(error) {
|
||||
return true
|
||||
}
|
||||
|
||||
if let apiError = error as? APIError,
|
||||
case let .networkError(message) = apiError {
|
||||
let lowercased = message.lowercased()
|
||||
return lowercased.contains("network error -999")
|
||||
|| lowercased.contains("network error -1005")
|
||||
|| lowercased.contains("network connection was lost")
|
||||
|| lowercased.contains("software caused connection abort")
|
||||
|| lowercased.contains("socket is not connected")
|
||||
}
|
||||
|
||||
let nsError = error as NSError
|
||||
if nsError.domain == NSURLErrorDomain {
|
||||
return nsError.code == URLError.cancelled.rawValue
|
||||
|| nsError.code == URLError.networkConnectionLost.rawValue
|
||||
|| nsError.code == URLError.notConnectedToInternet.rawValue
|
||||
|| nsError.code == URLError.timedOut.rawValue
|
||||
}
|
||||
|
||||
if nsError.domain == NSPOSIXErrorDomain {
|
||||
return nsError.code == 53 || nsError.code == 57
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func isCancellation(_ error: Error) -> Bool {
|
||||
if error is CancellationError {
|
||||
return true
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import ImageIO
|
||||
import Observation
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
|
||||
enum SybilWorkspaceNavigationLeadingControl {
|
||||
case back
|
||||
case hidden
|
||||
case showSidebar
|
||||
}
|
||||
|
||||
struct SybilWorkspaceView: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
var composerFocusRequest: Int = 0
|
||||
var usesCustomWorkspaceNavigation: Bool = true
|
||||
var navigationLeadingControl: SybilWorkspaceNavigationLeadingControl = .back
|
||||
var onShowSidebar: (() -> Void)? = nil
|
||||
var onRequestNewChat: (() -> Void)? = nil
|
||||
@FocusState private var composerFocused: Bool
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var isShowingAttachmentOptions = false
|
||||
@State private var isShowingFileImporter = false
|
||||
@State private var isShowingPhotoPicker = false
|
||||
@@ -23,6 +34,9 @@ struct SybilWorkspaceView: View {
|
||||
@State private var newChatSwipeDidTriggerHaptic = false
|
||||
@State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator?
|
||||
|
||||
private let customWorkspaceNavigationContentInset: CGFloat = 96
|
||||
private let composerOverlayContentInset: CGFloat = 112
|
||||
|
||||
private var isSettingsSelected: Bool {
|
||||
if case .settings = viewModel.selectedItem {
|
||||
return true
|
||||
@@ -34,6 +48,10 @@ struct SybilWorkspaceView: View {
|
||||
viewModel.errorMessage != nil
|
||||
}
|
||||
|
||||
private var showsCustomWorkspaceNavigation: Bool {
|
||||
usesCustomWorkspaceNavigation && !isSettingsSelected
|
||||
}
|
||||
|
||||
private var transcriptScrollContextID: String {
|
||||
if viewModel.draftKind == .chat {
|
||||
return "draft-chat"
|
||||
@@ -44,6 +62,14 @@ struct SybilWorkspaceView: View {
|
||||
return "chat:none"
|
||||
}
|
||||
|
||||
private var shouldAutoFocusComposer: Bool {
|
||||
viewModel.draftKind == .chat && viewModel.displayedMessages.isEmpty
|
||||
}
|
||||
|
||||
private var composerFocusPolicyID: String {
|
||||
"\(transcriptScrollContextID):\(composerFocusRequest):\(shouldAutoFocusComposer)"
|
||||
}
|
||||
|
||||
private var canSwipeToCreateChat: Bool {
|
||||
guard onRequestNewChat != nil else {
|
||||
return false
|
||||
@@ -84,16 +110,17 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
.offset(x: newChatSwipeCompletionOffset)
|
||||
.background(SybilTheme.background)
|
||||
.navigationTitle(viewModel.selectedTitle)
|
||||
.navigationTitle(showsCustomWorkspaceNavigation ? "" : viewModel.selectedTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarRole(.editor)
|
||||
.toolbar(showsCustomWorkspaceNavigation ? .hidden : .visible, for: .navigationBar)
|
||||
.toolbar {
|
||||
if !isSettingsSelected {
|
||||
if !isSettingsSelected && !showsCustomWorkspaceNavigation {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if viewModel.isSearchMode {
|
||||
searchModeChip
|
||||
} else {
|
||||
providerModelMenu
|
||||
providerModelToolbarMenu
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,12 +132,24 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
resetNewChatSwipe(animated: false)
|
||||
}
|
||||
.task(id: composerFocusRequest) {
|
||||
await focusComposerIfRequested()
|
||||
.task(id: composerFocusPolicyID) {
|
||||
await applyComposerFocusPolicy()
|
||||
}
|
||||
}
|
||||
|
||||
private var workspaceContent: some View {
|
||||
ZStack(alignment: .top) {
|
||||
workspaceContentStack
|
||||
|
||||
if showsCustomWorkspaceNavigation {
|
||||
SybilWorkspaceCharacterBackdrop(isBusy: viewModel.isSending)
|
||||
.allowsHitTesting(false)
|
||||
customWorkspaceNavigationBar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var workspaceContentStack: some View {
|
||||
VStack(spacing: 0) {
|
||||
if showsHeader {
|
||||
header
|
||||
@@ -119,15 +158,17 @@ struct SybilWorkspaceView: View {
|
||||
.overlay(SybilTheme.border)
|
||||
}
|
||||
|
||||
Group {
|
||||
ZStack(alignment: .bottom) {
|
||||
if isSettingsSelected {
|
||||
SybilSettingsView(viewModel: viewModel)
|
||||
} else if viewModel.isSearchMode {
|
||||
SybilSearchResultsView(
|
||||
search: viewModel.selectedSearch,
|
||||
search: viewModel.displayedSearch,
|
||||
isLoading: viewModel.isLoadingSelection,
|
||||
isRunning: viewModel.isSending,
|
||||
isStartingChat: viewModel.isCreatingSearchChat
|
||||
isRunning: viewModel.isRunningVisibleSearch,
|
||||
isStartingChat: viewModel.isCreatingSearchChat,
|
||||
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
||||
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0
|
||||
) {
|
||||
Task {
|
||||
await viewModel.startChatFromSelectedSearch()
|
||||
@@ -137,10 +178,16 @@ struct SybilWorkspaceView: View {
|
||||
SybilChatTranscriptView(
|
||||
messages: viewModel.displayedMessages,
|
||||
isLoading: viewModel.isLoadingSelection,
|
||||
isSending: viewModel.isSending
|
||||
isSending: viewModel.isSendingVisibleChat,
|
||||
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
||||
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0
|
||||
)
|
||||
.id(transcriptScrollContextID)
|
||||
}
|
||||
|
||||
if viewModel.showsComposer {
|
||||
composerBar
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background {
|
||||
@@ -161,12 +208,55 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.showsComposer {
|
||||
Divider()
|
||||
.overlay(SybilTheme.border)
|
||||
composerBar
|
||||
private var customWorkspaceNavigationBar: some View {
|
||||
HStack(spacing: 14) {
|
||||
workspaceNavigationLeadingControl
|
||||
|
||||
Text(viewModel.selectedTitle)
|
||||
.font(.sybil(size: 16, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
workspaceNavigationTrailingControl
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 34)
|
||||
.background(alignment: .top) {
|
||||
SybilNavigationFadeBackground()
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var workspaceNavigationLeadingControl: some View {
|
||||
switch navigationLeadingControl {
|
||||
case .back:
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
SybilNavigationIcon(systemImage: "chevron.left")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Back")
|
||||
|
||||
case .showSidebar:
|
||||
Button {
|
||||
onShowSidebar?()
|
||||
} label: {
|
||||
SybilNavigationIcon(systemImage: "sidebar.left")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Show sidebar")
|
||||
|
||||
case .hidden:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,15 +354,13 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func focusComposerIfRequested() async {
|
||||
guard composerFocusRequest > 0 else {
|
||||
private func applyComposerFocusPolicy() async {
|
||||
guard shouldAutoFocusComposer else {
|
||||
composerFocused = false
|
||||
return
|
||||
}
|
||||
|
||||
await Task.yield()
|
||||
try? await Task.sleep(for: .milliseconds(80))
|
||||
|
||||
guard viewModel.showsComposer, !viewModel.isSearchMode else {
|
||||
guard shouldAutoFocusComposer, viewModel.showsComposer else {
|
||||
return
|
||||
}
|
||||
composerFocused = true
|
||||
@@ -292,34 +380,8 @@ struct SybilWorkspaceView: View {
|
||||
.background(SybilTheme.panelGradient.opacity(0.58))
|
||||
}
|
||||
|
||||
private var providerModelMenu: some View {
|
||||
Menu {
|
||||
Text("\(viewModel.provider.displayName) • \(viewModel.model)")
|
||||
.font(.sybil(.caption))
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(Provider.allCases, id: \.self) { candidate in
|
||||
Menu(candidate.displayName) {
|
||||
let models = viewModel.modelOptions(for: candidate)
|
||||
if models.isEmpty {
|
||||
Text("No models")
|
||||
} else {
|
||||
ForEach(models, id: \.self) { candidateModel in
|
||||
Button {
|
||||
viewModel.setProvider(candidate, model: candidateModel)
|
||||
} label: {
|
||||
if viewModel.provider == candidate && viewModel.model == candidateModel {
|
||||
Label(candidateModel, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(candidateModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
private var providerModelToolbarMenu: some View {
|
||||
providerModelMenu {
|
||||
Image(systemName: "ellipsis")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
@@ -333,9 +395,68 @@ struct SybilWorkspaceView: View {
|
||||
.stroke(SybilTheme.border.opacity(0.82), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var providerModelNavigationMenu: some View {
|
||||
providerModelMenu {
|
||||
SybilNavigationIcon(systemImage: "ellipsis")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var workspaceNavigationTrailingControl: some View {
|
||||
if viewModel.isSearchMode {
|
||||
searchModeNavigationLabel
|
||||
} else {
|
||||
providerModelNavigationMenu
|
||||
}
|
||||
}
|
||||
|
||||
private var searchModeNavigationLabel: some View {
|
||||
Label("Search", systemImage: "globe")
|
||||
.font(.sybil(.caption, weight: .medium))
|
||||
.foregroundStyle(SybilTheme.accent)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
private func providerModelMenu<Label: View>(@ViewBuilder label: @escaping () -> Label) -> some View {
|
||||
Menu {
|
||||
providerModelMenuItems
|
||||
} label: {
|
||||
label()
|
||||
}
|
||||
.accessibilityLabel("Provider and model")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var providerModelMenuItems: some View {
|
||||
Text("\(viewModel.provider.displayName) • \(viewModel.model)")
|
||||
.font(.sybil(.caption))
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(Provider.allCases, id: \.self) { candidate in
|
||||
Menu(candidate.displayName) {
|
||||
let models = viewModel.modelOptions(for: candidate)
|
||||
if models.isEmpty {
|
||||
Text("No models")
|
||||
} else {
|
||||
ForEach(models, id: \.self) { candidateModel in
|
||||
Button {
|
||||
viewModel.setProvider(candidate, model: candidateModel)
|
||||
} label: {
|
||||
if viewModel.provider == candidate && viewModel.model == candidateModel {
|
||||
Label(candidateModel, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(candidateModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var searchModeChip: some View {
|
||||
Label("Search", systemImage: "globe")
|
||||
.font(.sybil(.caption, weight: .medium))
|
||||
@@ -387,7 +508,7 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
|
||||
TextField(
|
||||
viewModel.isSearchMode ? "Search the web" : "Message Sybil",
|
||||
viewModel.isSearchMode ? "Search the web" : "Enter Prompt",
|
||||
text: $viewModel.composer,
|
||||
axis: .vertical
|
||||
)
|
||||
@@ -397,26 +518,19 @@ struct SybilWorkspaceView: View {
|
||||
.lineLimit(1 ... 6)
|
||||
.submitLabel(.send)
|
||||
.onSubmit {
|
||||
Task {
|
||||
await viewModel.sendComposer()
|
||||
}
|
||||
submitComposer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(SybilTheme.composerGradient)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(SybilTheme.primary.opacity(0.34), lineWidth: 1)
|
||||
)
|
||||
.opacity(0.98)
|
||||
)
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.sendComposer()
|
||||
}
|
||||
submitComposer()
|
||||
} label: {
|
||||
Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
@@ -436,23 +550,19 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
SybilTheme.background.opacity(0.18),
|
||||
SybilTheme.background.opacity(0.96)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.padding(.top, 34)
|
||||
.padding(.bottom, 12)
|
||||
.background(alignment: .bottom) {
|
||||
SybilComposerFadeBackground()
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.overlay {
|
||||
if isComposerDropTargeted && !viewModel.isSearchMode {
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.stroke(SybilTheme.accent.opacity(0.78), style: StrokeStyle(lineWidth: 1.5, dash: [7, 5]))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.top, 32)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
.onDrop(of: [UTType.fileURL.identifier, UTType.image.identifier], isTargeted: $isComposerDropTargeted) { providers in
|
||||
@@ -527,6 +637,22 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func submitComposer() {
|
||||
guard viewModel.canSendComposer else {
|
||||
return
|
||||
}
|
||||
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
if !viewModel.isSearchMode {
|
||||
composerFocused = false
|
||||
}
|
||||
#endif
|
||||
|
||||
Task {
|
||||
await viewModel.sendComposer()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func importAttachmentsFromItemProviders(_ providers: [NSItemProvider]) async {
|
||||
do {
|
||||
@@ -856,6 +982,274 @@ private extension UIView {
|
||||
}
|
||||
}
|
||||
|
||||
private struct SybilComposerFadeBackground: View {
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.clear,
|
||||
SybilTheme.background.opacity(0.30),
|
||||
SybilTheme.background.opacity(0.86),
|
||||
SybilTheme.background.opacity(0.86),
|
||||
SybilTheme.background.opacity(0.98)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
|
||||
LinearGradient(
|
||||
colors: [
|
||||
SybilTheme.primary.opacity(0.18),
|
||||
SybilTheme.surface.opacity(0.16),
|
||||
SybilTheme.accent.opacity(0.08)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.mask(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.clear,
|
||||
Color.black.opacity(0.42),
|
||||
Color.black
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.blendMode(.screen)
|
||||
|
||||
RadialGradient(
|
||||
colors: [
|
||||
SybilTheme.primary.opacity(0.28),
|
||||
SybilTheme.primary.opacity(0.08),
|
||||
Color.clear
|
||||
],
|
||||
center: .bottomLeading,
|
||||
startRadius: 8,
|
||||
endRadius: 180
|
||||
)
|
||||
.blendMode(.screen)
|
||||
.offset(x: -42, y: 42)
|
||||
}
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SybilNavigationIcon: View {
|
||||
var systemImage: String
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 21, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.frame(width: 46, height: 46)
|
||||
.contentShape(Rectangle())
|
||||
.shadow(color: SybilTheme.primary.opacity(0.34), radius: 12, x: 0, y: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SybilNavigationFadeBackground: View {
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
SybilTheme.background.opacity(1.0),
|
||||
SybilTheme.background.opacity(0.90),
|
||||
SybilTheme.background.opacity(0.80),
|
||||
SybilTheme.background.opacity(0.80),
|
||||
SybilTheme.background.opacity(0.28),
|
||||
Color.clear
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
|
||||
RadialGradient(
|
||||
colors: [
|
||||
SybilTheme.primary.opacity(0.36),
|
||||
SybilTheme.primary.opacity(0.10),
|
||||
Color.clear
|
||||
],
|
||||
center: .topLeading,
|
||||
startRadius: 6,
|
||||
endRadius: 210
|
||||
)
|
||||
.blendMode(.screen)
|
||||
.offset(x: -44, y: -46)
|
||||
}
|
||||
.frame(height: 200.0)
|
||||
.ignoresSafeArea(edges: .top)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SybilWorkspaceCharacterBackdrop: View {
|
||||
var isBusy: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
RadialGradient(
|
||||
colors: [
|
||||
SybilTheme.primary.opacity(0.36),
|
||||
SybilTheme.primary.opacity(0.13),
|
||||
Color.clear
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 10,
|
||||
endRadius: 150
|
||||
)
|
||||
.frame(width: 136, height: 118)
|
||||
.blur(radius: 7)
|
||||
.offset(x: 28, y: -24)
|
||||
|
||||
SybilAnimatedGIFView(resourceName: isBusy ? "character-busy" : "character-idle")
|
||||
.frame(width: 172, height: 172)
|
||||
.opacity(0.92)
|
||||
.mask {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.black,
|
||||
Color.black,
|
||||
Color.black.opacity(0)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
}
|
||||
.mask {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.clear,
|
||||
Color.black.opacity(0.98),
|
||||
Color.black
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
}
|
||||
.offset(x: 8, y: 4)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 156, maxHeight: 156, alignment: .topTrailing)
|
||||
.clipped()
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SybilAnimatedGIFView: UIViewRepresentable {
|
||||
var resourceName: String
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UIImageView {
|
||||
let imageView = SybilAnimatedUIImageView()
|
||||
imageView.backgroundColor = .clear
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.clipsToBounds = false
|
||||
imageView.isOpaque = false
|
||||
imageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
imageView.setContentHuggingPriority(.defaultLow, for: .vertical)
|
||||
imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||
return imageView
|
||||
}
|
||||
|
||||
func updateUIView(_ imageView: UIImageView, context: Context) {
|
||||
guard context.coordinator.resourceName != resourceName else {
|
||||
if !imageView.isAnimating {
|
||||
imageView.startAnimating()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
context.coordinator.resourceName = resourceName
|
||||
imageView.image = SybilAnimatedGIFCache.image(named: resourceName)
|
||||
imageView.startAnimating()
|
||||
}
|
||||
|
||||
final class Coordinator {
|
||||
var resourceName: String?
|
||||
}
|
||||
}
|
||||
|
||||
private final class SybilAnimatedUIImageView: UIImageView {
|
||||
override var intrinsicContentSize: CGSize {
|
||||
CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private enum SybilAnimatedGIFCache {
|
||||
private static var images: [String: UIImage] = [:]
|
||||
|
||||
static func image(named name: String) -> UIImage? {
|
||||
if let cached = images[name] {
|
||||
return cached
|
||||
}
|
||||
|
||||
guard let image = loadImage(named: name) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
images[name] = image
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
private static func loadImage(named name: String) -> UIImage? {
|
||||
let url = Bundle.main.url(forResource: name, withExtension: "gif", subdirectory: "Character") ??
|
||||
Bundle.main.url(forResource: name, withExtension: "gif")
|
||||
|
||||
guard let url,
|
||||
let data = try? Data(contentsOf: url),
|
||||
let source = CGImageSourceCreateWithData(data as CFData, nil)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let frameCount = CGImageSourceGetCount(source)
|
||||
guard frameCount > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var frames: [UIImage] = []
|
||||
frames.reserveCapacity(frameCount)
|
||||
var duration: TimeInterval = 0
|
||||
|
||||
for index in 0 ..< frameCount {
|
||||
guard let cgImage = CGImageSourceCreateImageAtIndex(source, index, nil) else {
|
||||
continue
|
||||
}
|
||||
frames.append(UIImage(cgImage: cgImage))
|
||||
duration += frameDuration(at: index, source: source)
|
||||
}
|
||||
|
||||
guard !frames.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if frames.count == 1 {
|
||||
return frames[0]
|
||||
}
|
||||
|
||||
return UIImage.animatedImage(with: frames, duration: max(duration, 0.1))
|
||||
}
|
||||
|
||||
private static func frameDuration(at index: Int, source: CGImageSource) -> TimeInterval {
|
||||
guard let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any],
|
||||
let gifProperties = properties[kCGImagePropertyGIFDictionary] as? [CFString: Any]
|
||||
else {
|
||||
return 0.08
|
||||
}
|
||||
|
||||
let unclampedDelay = gifProperties[kCGImagePropertyGIFUnclampedDelayTime] as? TimeInterval
|
||||
let delay = unclampedDelay ?? gifProperties[kCGImagePropertyGIFDelayTime] as? TimeInterval ?? 0.08
|
||||
return max(delay, 0.03)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NewChatSwipeBackdrop: View {
|
||||
var progress: CGFloat
|
||||
var hasLatched: Bool
|
||||
@@ -913,15 +1307,10 @@ private struct NewChatSwipeBackdrop: View {
|
||||
.frame(width: 72, height: 72)
|
||||
.blur(radius: 10)
|
||||
|
||||
Image(systemName: hasLatched ? "checkmark" : "plus")
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 31, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.symbolEffect(.bounce, value: hasLatched)
|
||||
|
||||
Image(systemName: "sparkle")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle((hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.90))
|
||||
.offset(x: -26, y: -25)
|
||||
}
|
||||
.frame(width: 92, height: 92)
|
||||
.background(
|
||||
|
||||
@@ -17,25 +17,52 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
private let searchesResponse: [SearchSummary]
|
||||
private let chatDetails: [String: ChatDetail]
|
||||
private let searchDetails: [String: SearchDetail]
|
||||
private let createChatResponse: ChatSummary?
|
||||
|
||||
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(
|
||||
chatsResponse: [ChatSummary] = [],
|
||||
searchesResponse: [SearchSummary] = [],
|
||||
chatDetails: [String: ChatDetail] = [:],
|
||||
searchDetails: [String: SearchDetail] = [:]
|
||||
searchDetails: [String: SearchDetail] = [:],
|
||||
createChatResponse: ChatSummary? = nil
|
||||
) {
|
||||
self.chatsResponse = chatsResponse
|
||||
self.searchesResponse = searchesResponse
|
||||
self.chatDetails = chatDetails
|
||||
self.searchDetails = searchDetails
|
||||
self.createChatResponse = createChatResponse
|
||||
}
|
||||
|
||||
func currentSnapshot() -> MockClientCallSnapshot {
|
||||
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 {
|
||||
AuthSession(authenticated: true, mode: "open")
|
||||
}
|
||||
@@ -46,11 +73,17 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
}
|
||||
|
||||
func createChat(title: String?) async throws -> ChatSummary {
|
||||
if let createChatResponse {
|
||||
return createChatResponse
|
||||
}
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
|
||||
func getChat(chatID: String) async throws -> ChatDetail {
|
||||
snapshot.getChat += 1
|
||||
if getChatDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: getChatDelayNanoseconds)
|
||||
}
|
||||
guard let detail = chatDetails[chatID] else {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
@@ -76,6 +109,9 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
|
||||
func getSearch(searchID: String) async throws -> SearchDetail {
|
||||
snapshot.getSearch += 1
|
||||
if getSearchDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: getSearchDelayNanoseconds)
|
||||
}
|
||||
guard let detail = searchDetails[searchID] else {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
@@ -98,6 +134,12 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
body: CompletionStreamRequest,
|
||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||
) async throws {
|
||||
if completionStreamDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
|
||||
}
|
||||
if let completionStreamNetworkErrorMessage {
|
||||
throw APIError.networkError(message: completionStreamNetworkErrorMessage)
|
||||
}
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
|
||||
@@ -106,6 +148,12 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
body: SearchRunRequest,
|
||||
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||
) async throws {
|
||||
if searchStreamDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: searchStreamDelayNanoseconds)
|
||||
}
|
||||
if let searchStreamNetworkErrorMessage {
|
||||
throw APIError.networkError(message: searchStreamNetworkErrorMessage)
|
||||
}
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
}
|
||||
@@ -267,6 +315,178 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
||||
#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 {
|
||||
let width: CGFloat = 390
|
||||
let maxTravel = NewChatSwipeMetrics.maxTravel(for: width)
|
||||
|
||||
22
ios/justfile
22
ios/justfile
@@ -1,10 +1,28 @@
|
||||
simulator := "platform=iOS Simulator,name=iPhone 16e,OS=latest"
|
||||
simulator_name := "iPhone 16e"
|
||||
derived_data := "build/DerivedData"
|
||||
|
||||
default:
|
||||
@just build
|
||||
|
||||
build:
|
||||
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
|
||||
if command -v xcbeautify >/dev/null 2>&1; then \
|
||||
xcodebuild -scheme Sybil -destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' | xcbeautify; \
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
|
||||
else \
|
||||
xcodebuild -scheme Sybil -destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest'; \
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}'; \
|
||||
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}}'
|
||||
|
||||
BIN
web/public/StalinistOne-Regular.ttf
Normal file
BIN
web/public/StalinistOne-Regular.ttf
Normal file
Binary file not shown.
BIN
web/public/character-busy.gif
Normal file
BIN
web/public/character-busy.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
BIN
web/public/character-idle.gif
Normal file
BIN
web/public/character-idle.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
@@ -7,6 +7,7 @@ import { AuthScreen } from "@/components/auth/auth-screen";
|
||||
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
||||
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
|
||||
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
||||
import { SybilCharacter } from "@/components/sybil-character";
|
||||
import {
|
||||
createChat,
|
||||
createChatFromSearch,
|
||||
@@ -1989,14 +1990,20 @@ export default function App() {
|
||||
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
||||
)}
|
||||
>
|
||||
<div className="px-4 pb-4 pt-5">
|
||||
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
|
||||
SYBIL
|
||||
<div className="relative min-h-24 px-4 pb-4 pt-5">
|
||||
<div className="pr-24">
|
||||
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
|
||||
SYBIL
|
||||
</div>
|
||||
<p className="mt-2 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-2 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
|
||||
</p>
|
||||
<SybilCharacter
|
||||
className="absolute right-4 top-3 h-[calc(100%-1.5rem)]"
|
||||
isBusy={isSending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 px-3 pb-3">
|
||||
@@ -2246,7 +2253,7 @@ export default function App() {
|
||||
void handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder={isSearchMode ? "Search the web" : "Message Sybil..."}
|
||||
placeholder={isSearchMode ? "Search the web" : "Enter prompt..."}
|
||||
className="max-h-40 min-h-0 resize-none overflow-y-auto border-0 bg-transparent px-3 py-3 text-base text-violet-50 shadow-none placeholder:text-violet-200/45 focus-visible:ring-0"
|
||||
disabled={isSending}
|
||||
/>
|
||||
|
||||
31
web/src/components/sybil-character.tsx
Normal file
31
web/src/components/sybil-character.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useEffect } from "preact/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const CHARACTER_IDLE_SRC = "/character-idle.gif";
|
||||
const CHARACTER_BUSY_SRC = "/character-busy.gif";
|
||||
|
||||
type SybilCharacterProps = {
|
||||
className?: string;
|
||||
isBusy?: boolean;
|
||||
};
|
||||
|
||||
export function SybilCharacter({ className, isBusy = false }: SybilCharacterProps) {
|
||||
useEffect(() => {
|
||||
const busyImage = new Image();
|
||||
busyImage.src = CHARACTER_BUSY_SRC;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<img
|
||||
aria-hidden="true"
|
||||
alt=""
|
||||
className={cn(
|
||||
"aspect-square rounded-xl border border-violet-200/24 bg-white/6 object-cover p-1 shadow-[inset_0_1px_0_hsl(252_90%_86%/0.12),0_10px_24px_hsl(240_80%_2%/0.3)]",
|
||||
className
|
||||
)}
|
||||
data-state={isBusy ? "busy" : "idle"}
|
||||
draggable={false}
|
||||
src={isBusy ? CHARACTER_BUSY_SRC : CHARACTER_IDLE_SRC}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,14 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@font-face {
|
||||
font-family: "StalinistOne";
|
||||
src: url("/StalinistOne-Regular.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--background: 235 45% 4%;
|
||||
@@ -57,8 +65,8 @@ textarea {
|
||||
}
|
||||
|
||||
.sybil-wordmark {
|
||||
font-family: "Orbitron", "Inter", sans-serif;
|
||||
font-weight: 900;
|
||||
font-family: "StalinistOne", "Orbitron", "Inter", sans-serif;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -184,7 +192,13 @@ textarea {
|
||||
margin-top: 0.65rem;
|
||||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
list-style-position: inside;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.md-content li > ul,
|
||||
.md-content li > ol {
|
||||
margin-top: 0.3rem;
|
||||
padding-left: 1.35rem;
|
||||
}
|
||||
|
||||
.md-content li + li {
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user