more consistent view model display between switching chats
This commit is contained in:
@@ -77,6 +77,27 @@ struct SybilPhoneShellView: View {
|
|||||||
private struct SybilPhoneSidebarRoot: View {
|
private struct SybilPhoneSidebarRoot: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
@Binding var path: [PhoneRoute]
|
@Binding var path: [PhoneRoute]
|
||||||
|
@State private var openingSelection: SidebarSelection?
|
||||||
|
@State private var openingRequestID: UUID?
|
||||||
|
|
||||||
|
private var highlightedSelection: SidebarSelection? {
|
||||||
|
if let openingSelection {
|
||||||
|
return openingSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let route = path.last else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch route {
|
||||||
|
case let .chat(chatID):
|
||||||
|
return .chat(chatID)
|
||||||
|
case let .search(searchID):
|
||||||
|
return .search(searchID)
|
||||||
|
case .draftChat, .draftSearch, .settings:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -118,10 +139,16 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(viewModel.sidebarItems) { item in
|
ForEach(viewModel.sidebarItems) { item in
|
||||||
NavigationLink(value: PhoneRoute.from(selection: item.selection)) {
|
Button {
|
||||||
|
open(item.selection)
|
||||||
|
} label: {
|
||||||
SybilPhoneSidebarRow(item: item)
|
SybilPhoneSidebarRow(item: item)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(
|
||||||
|
SybilPhoneSidebarRowButtonStyle(
|
||||||
|
isHighlighted: highlightedSelection == item.selection
|
||||||
|
)
|
||||||
|
)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
Task {
|
Task {
|
||||||
@@ -150,17 +177,20 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
|
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
|
||||||
|
clearOpeningSelection()
|
||||||
path = [.settings]
|
path = [.settings]
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
|
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
|
||||||
|
clearOpeningSelection()
|
||||||
viewModel.startNewSearch()
|
viewModel.startNewSearch()
|
||||||
path = [.draftSearch]
|
path = [.draftSearch]
|
||||||
}
|
}
|
||||||
|
|
||||||
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
|
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
|
||||||
|
clearOpeningSelection()
|
||||||
viewModel.startNewChat()
|
viewModel.startNewChat()
|
||||||
path = [.draftChat]
|
path = [.draftChat]
|
||||||
}
|
}
|
||||||
@@ -198,9 +228,54 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(accessibilityLabel)
|
.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 {
|
private struct SybilPhoneSidebarRow: View {
|
||||||
|
@Environment(\.sybilPhoneSidebarRowIsActive) private var isHighlighted
|
||||||
var item: SidebarItem
|
var item: SidebarItem
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -208,14 +283,14 @@ private struct SybilPhoneSidebarRow: View {
|
|||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: item.kind == .chat ? "message" : "globe")
|
Image(systemName: item.kind == .chat ? "message" : "globe")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.system(size: 12, weight: .semibold))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(isHighlighted ? SybilTheme.accent : SybilTheme.textMuted)
|
||||||
.frame(width: 22, height: 22)
|
.frame(width: 22, height: 22)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 7)
|
RoundedRectangle(cornerRadius: 7)
|
||||||
.fill(SybilTheme.surface.opacity(0.72))
|
.fill(isHighlighted ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 7)
|
RoundedRectangle(cornerRadius: 7)
|
||||||
.stroke(SybilTheme.border.opacity(0.72), lineWidth: 1)
|
.stroke(isHighlighted ? SybilTheme.accent.opacity(0.36) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -246,11 +321,15 @@ private struct SybilPhoneSidebarRow: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.fill(LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
.fill(
|
||||||
|
isHighlighted
|
||||||
|
? SybilTheme.selectedRowGradient
|
||||||
|
: LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.stroke(SybilTheme.border.opacity(0.72), lineWidth: 1)
|
.stroke(isHighlighted ? SybilTheme.primary.opacity(0.55) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,8 +364,14 @@ private struct SybilPhoneDestinationView: View {
|
|||||||
private func applyRoute() {
|
private func applyRoute() {
|
||||||
switch route {
|
switch route {
|
||||||
case let .chat(chatID):
|
case let .chat(chatID):
|
||||||
|
guard viewModel.draftKind != nil || viewModel.selectedItem != .chat(chatID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
viewModel.select(.chat(chatID))
|
viewModel.select(.chat(chatID))
|
||||||
case let .search(searchID):
|
case let .search(searchID):
|
||||||
|
guard viewModel.draftKind != nil || viewModel.selectedItem != .search(searchID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
viewModel.select(.search(searchID))
|
viewModel.select(.search(searchID))
|
||||||
case .draftChat:
|
case .draftChat:
|
||||||
viewModel.startNewChat()
|
viewModel.startNewChat()
|
||||||
|
|||||||
@@ -42,6 +42,22 @@ private struct PendingChatState {
|
|||||||
var messages: [Message]
|
var messages: [Message]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum ActiveSendContext: Equatable {
|
||||||
|
case draftChat(UUID)
|
||||||
|
case chat(String)
|
||||||
|
case draftSearch(UUID)
|
||||||
|
case search(String)
|
||||||
|
|
||||||
|
var isSearch: Bool {
|
||||||
|
switch self {
|
||||||
|
case .draftSearch, .search:
|
||||||
|
return true
|
||||||
|
case .draftChat, .chat:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private actor CompletionStreamStatus {
|
private actor CompletionStreamStatus {
|
||||||
private var streamError: String?
|
private var streamError: String?
|
||||||
|
|
||||||
@@ -99,6 +115,8 @@ final class SybilViewModel {
|
|||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private var hasBootstrapped = false
|
private var hasBootstrapped = false
|
||||||
private var pendingChatState: PendingChatState?
|
private var pendingChatState: PendingChatState?
|
||||||
|
private var activeSendContext: ActiveSendContext?
|
||||||
|
private var draftIdentity = UUID()
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private var selectionTask: Task<Void, Never>?
|
private var selectionTask: Task<Void, Never>?
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
@@ -157,7 +175,7 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
switch selectedItem {
|
switch selectedItem {
|
||||||
case .chat:
|
case .chat:
|
||||||
if let selectedChat {
|
if let selectedChat = currentSelectedChat {
|
||||||
return chatTitle(title: selectedChat.title, messages: selectedChat.messages)
|
return chatTitle(title: selectedChat.title, messages: selectedChat.messages)
|
||||||
}
|
}
|
||||||
if let summary = selectedChatSummary {
|
if let summary = selectedChatSummary {
|
||||||
@@ -166,7 +184,7 @@ final class SybilViewModel {
|
|||||||
return "Chat"
|
return "Chat"
|
||||||
|
|
||||||
case .search:
|
case .search:
|
||||||
if let selectedSearch {
|
if let selectedSearch = currentSelectedSearch {
|
||||||
return searchTitle(title: selectedSearch.title, query: selectedSearch.query)
|
return searchTitle(title: selectedSearch.title, query: selectedSearch.query)
|
||||||
}
|
}
|
||||||
if let summary = selectedSearchSummary {
|
if let summary = selectedSearchSummary {
|
||||||
@@ -233,7 +251,7 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var displayedMessages: [Message] {
|
var displayedMessages: [Message] {
|
||||||
let canonical = displayableMessages(selectedChat?.messages ?? [])
|
let canonical = displayableMessages(currentSelectedChat?.messages ?? [])
|
||||||
guard let pending = pendingChatState else {
|
guard let pending = pendingChatState else {
|
||||||
return canonical
|
return canonical
|
||||||
}
|
}
|
||||||
@@ -252,6 +270,40 @@ final class SybilViewModel {
|
|||||||
return canonical
|
return canonical
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var displayedSearch: SearchDetail? {
|
||||||
|
currentSelectedSearch
|
||||||
|
}
|
||||||
|
|
||||||
|
var isSendingVisibleChat: Bool {
|
||||||
|
guard isSending, pendingChatState != nil else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch activeSendContext {
|
||||||
|
case let .draftChat(identity):
|
||||||
|
return draftKind == .chat && identity == draftIdentity
|
||||||
|
case let .chat(chatID):
|
||||||
|
return selectedItem == .chat(chatID)
|
||||||
|
case .draftSearch, .search, nil:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isRunningVisibleSearch: Bool {
|
||||||
|
guard isSending else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch activeSendContext {
|
||||||
|
case let .draftSearch(identity):
|
||||||
|
return draftKind == .search && identity == draftIdentity
|
||||||
|
case let .search(searchID):
|
||||||
|
return selectedItem == .search(searchID)
|
||||||
|
case .draftChat, .chat, nil:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var sidebarItems: [SidebarItem] {
|
var sidebarItems: [SidebarItem] {
|
||||||
let chatItems: [SidebarItem] = chats.map { chat in
|
let chatItems: [SidebarItem] = chats.map { chat in
|
||||||
let initiatedLabel: String?
|
let initiatedLabel: String?
|
||||||
@@ -324,7 +376,10 @@ final class SybilViewModel {
|
|||||||
isCheckingSession = true
|
isCheckingSession = true
|
||||||
authError = nil
|
authError = nil
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
resetSelectionLoading()
|
||||||
pendingChatState = nil
|
pendingChatState = nil
|
||||||
|
activeSendContext = nil
|
||||||
|
draftIdentity = UUID()
|
||||||
composerAttachments = []
|
composerAttachments = []
|
||||||
settings.persist()
|
settings.persist()
|
||||||
|
|
||||||
@@ -396,10 +451,13 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
func startNewChat() {
|
func startNewChat() {
|
||||||
SybilLog.debug(SybilLog.ui, "Starting draft chat")
|
SybilLog.debug(SybilLog.ui, "Starting draft chat")
|
||||||
|
resetSelectionLoading()
|
||||||
|
draftIdentity = UUID()
|
||||||
draftKind = .chat
|
draftKind = .chat
|
||||||
selectedItem = nil
|
selectedItem = nil
|
||||||
selectedChat = nil
|
selectedChat = nil
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
|
pendingChatState = nil
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
composer = ""
|
composer = ""
|
||||||
composerAttachments = []
|
composerAttachments = []
|
||||||
@@ -407,10 +465,13 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
func startNewSearch() {
|
func startNewSearch() {
|
||||||
SybilLog.debug(SybilLog.ui, "Starting draft search")
|
SybilLog.debug(SybilLog.ui, "Starting draft search")
|
||||||
|
resetSelectionLoading()
|
||||||
|
draftIdentity = UUID()
|
||||||
draftKind = .search
|
draftKind = .search
|
||||||
selectedItem = nil
|
selectedItem = nil
|
||||||
selectedChat = nil
|
selectedChat = nil
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
|
pendingChatState = nil
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
composer = ""
|
composer = ""
|
||||||
composerAttachments = []
|
composerAttachments = []
|
||||||
@@ -418,33 +479,72 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
func openSettings() {
|
func openSettings() {
|
||||||
SybilLog.debug(SybilLog.ui, "Opening settings")
|
SybilLog.debug(SybilLog.ui, "Opening settings")
|
||||||
|
resetSelectionLoading()
|
||||||
draftKind = nil
|
draftKind = nil
|
||||||
selectedItem = .settings
|
selectedItem = .settings
|
||||||
selectedChat = nil
|
selectedChat = nil
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
|
pendingChatState = nil
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
composerAttachments = []
|
composerAttachments = []
|
||||||
}
|
}
|
||||||
|
|
||||||
func select(_ selection: SidebarSelection) {
|
func select(_ selection: SidebarSelection) {
|
||||||
SybilLog.debug(SybilLog.ui, "Selecting \(selection.id)")
|
_ = beginSelecting(selection)
|
||||||
draftKind = nil
|
}
|
||||||
selectedItem = selection
|
|
||||||
errorMessage = nil
|
|
||||||
if case .search = selection {
|
|
||||||
composerAttachments = []
|
|
||||||
}
|
|
||||||
|
|
||||||
if case .settings = selection {
|
func selectForNavigation(_ selection: SidebarSelection, preloadTimeout: Duration = .seconds(3)) async {
|
||||||
selectedChat = nil
|
guard beginSelecting(selection) != nil else {
|
||||||
selectedSearch = nil
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
selectionTask?.cancel()
|
await waitForSelectionLoad(timeout: preloadTimeout)
|
||||||
selectionTask = Task { [weak self] in
|
}
|
||||||
await self?.refreshSelectionIfNeeded()
|
|
||||||
|
@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() {
|
func selectPreviousSidebarItem() {
|
||||||
@@ -565,12 +665,13 @@ final class SybilViewModel {
|
|||||||
func sendComposer() async {
|
func sendComposer() async {
|
||||||
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let attachments = composerAttachments
|
let attachments = composerAttachments
|
||||||
|
let sendContext = currentSendContext
|
||||||
|
|
||||||
guard !isSending else {
|
guard !isSending else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if isSearchMode {
|
if sendContext.isSearch {
|
||||||
guard !content.isEmpty else { return }
|
guard !content.isEmpty else { return }
|
||||||
} else if content.isEmpty && attachments.isEmpty {
|
} else if content.isEmpty && attachments.isEmpty {
|
||||||
return
|
return
|
||||||
@@ -579,10 +680,11 @@ final class SybilViewModel {
|
|||||||
composer = ""
|
composer = ""
|
||||||
composerAttachments = []
|
composerAttachments = []
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
activeSendContext = sendContext
|
||||||
isSending = true
|
isSending = true
|
||||||
|
|
||||||
do {
|
do {
|
||||||
if isSearchMode {
|
if sendContext.isSearch {
|
||||||
SybilLog.info(SybilLog.ui, "Sending search query")
|
SybilLog.info(SybilLog.ui, "Sending search query")
|
||||||
try await sendSearch(query: content)
|
try await sendSearch(query: content)
|
||||||
} else {
|
} else {
|
||||||
@@ -590,35 +692,43 @@ final class SybilViewModel {
|
|||||||
try await sendChat(content: content, attachments: attachments)
|
try await sendChat(content: content, attachments: attachments)
|
||||||
}
|
}
|
||||||
} catch {
|
} 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)
|
SybilLog.error(SybilLog.ui, "Send failed", error: error)
|
||||||
|
|
||||||
if case let .chat(chatID) = selectedItem {
|
if shouldSurfaceError, case let .chat(chatID) = selectedItem {
|
||||||
do {
|
do {
|
||||||
let chat = try await client().getChat(chatID: chatID)
|
let chat = try await client().getChat(chatID: chatID)
|
||||||
selectedChat = chat
|
if selectedItem == .chat(chatID), draftKind == nil {
|
||||||
|
selectedChat = chat
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SybilLog.error(SybilLog.ui, "Fallback chat refresh after failure failed", error: error)
|
SybilLog.error(SybilLog.ui, "Fallback chat refresh after failure failed", error: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if case let .search(searchID) = selectedItem {
|
if shouldSurfaceError, case let .search(searchID) = selectedItem {
|
||||||
do {
|
do {
|
||||||
let search = try await client().getSearch(searchID: searchID)
|
let search = try await client().getSearch(searchID: searchID)
|
||||||
selectedSearch = search
|
if selectedItem == .search(searchID), draftKind == nil {
|
||||||
|
selectedSearch = search
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SybilLog.error(SybilLog.ui, "Fallback search refresh after failure failed", error: error)
|
SybilLog.error(SybilLog.ui, "Fallback search refresh after failure failed", error: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isSearchMode {
|
if !sendContext.isSearch, shouldSurfaceError {
|
||||||
composer = content
|
composer = content
|
||||||
composerAttachments = attachments
|
composerAttachments = attachments
|
||||||
pendingChatState = nil
|
|
||||||
}
|
}
|
||||||
|
pendingChatState = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
isSending = false
|
isSending = false
|
||||||
|
activeSendContext = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func appendComposerAttachments(_ attachments: [ChatAttachment]) throws {
|
func appendComposerAttachments(_ attachments: [ChatAttachment]) throws {
|
||||||
@@ -644,16 +754,25 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startChatFromSelectedSearch() async {
|
func startChatFromSelectedSearch() async {
|
||||||
guard let search = selectedSearch, !isCreatingSearchChat, !isSending else {
|
guard let search = currentSelectedSearch, !isCreatingSearchChat, !isSending else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let sourceSelection = SidebarSelection.search(search.id)
|
||||||
isCreatingSearchChat = true
|
isCreatingSearchChat = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let client = try client()
|
let client = try client()
|
||||||
let chat = try await client.createChatFromSearch(searchID: search.id, title: nil)
|
let chat = try await client.createChatFromSearch(searchID: search.id, title: nil)
|
||||||
|
|
||||||
|
guard selectedItem == sourceSelection, draftKind == nil else {
|
||||||
|
chats.removeAll(where: { $0.id == chat.id })
|
||||||
|
chats.insert(chat, at: 0)
|
||||||
|
isCreatingSearchChat = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
draftKind = nil
|
draftKind = nil
|
||||||
pendingChatState = nil
|
pendingChatState = nil
|
||||||
composer = ""
|
composer = ""
|
||||||
@@ -767,6 +886,11 @@ final class SybilViewModel {
|
|||||||
)
|
)
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
|
if draftKind != nil {
|
||||||
|
isLoadingCollections = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if case .settings = selectedItem {
|
if case .settings = selectedItem {
|
||||||
isLoadingCollections = false
|
isLoadingCollections = false
|
||||||
return
|
return
|
||||||
@@ -797,26 +921,68 @@ final class SybilViewModel {
|
|||||||
isLoadingCollections = false
|
isLoadingCollections = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func resetSelectionLoading() {
|
||||||
|
selectionTask?.cancel()
|
||||||
|
selectionTask = nil
|
||||||
|
isLoadingSelection = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startSelectionRefreshTask() -> Task<Void, Never> {
|
||||||
|
isLoadingSelection = true
|
||||||
|
let task = Task { [weak self] in
|
||||||
|
guard let self else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await self.refreshSelectionIfNeeded()
|
||||||
|
}
|
||||||
|
selectionTask = task
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForSelectionLoad(timeout: Duration) async {
|
||||||
|
let clock = ContinuousClock()
|
||||||
|
let deadline = clock.now.advanced(by: timeout)
|
||||||
|
|
||||||
|
while isLoadingSelection, clock.now < deadline {
|
||||||
|
try? await Task.sleep(for: .milliseconds(10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func needsSelectionLoad(_ selection: SidebarSelection) -> Bool {
|
||||||
|
switch selection {
|
||||||
|
case let .chat(chatID):
|
||||||
|
return selectedChat?.id != chatID
|
||||||
|
case let .search(searchID):
|
||||||
|
return selectedSearch?.id != searchID
|
||||||
|
case .settings:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func refreshSelectionIfNeeded() async {
|
private func refreshSelectionIfNeeded() async {
|
||||||
guard let selectedItem else {
|
guard let target = selectedItem else {
|
||||||
selectedChat = nil
|
selectedChat = nil
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
|
isLoadingSelection = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard case .settings = selectedItem else {
|
guard case .settings = target else {
|
||||||
isLoadingSelection = true
|
isLoadingSelection = true
|
||||||
do {
|
do {
|
||||||
let client = try client()
|
let client = try client()
|
||||||
switch selectedItem {
|
switch target {
|
||||||
case let .chat(chatID):
|
case let .chat(chatID):
|
||||||
SybilLog.debug(SybilLog.app, "Refreshing 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
|
selectedSearch = nil
|
||||||
|
|
||||||
if let detail = selectedChat,
|
if let provider = chat.lastUsedProvider,
|
||||||
let provider = detail.lastUsedProvider,
|
let model = chat.lastUsedModel,
|
||||||
let model = detail.lastUsedModel,
|
|
||||||
!model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
!model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
self.model = model
|
self.model = model
|
||||||
@@ -824,7 +990,11 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
case let .search(searchID):
|
case let .search(searchID):
|
||||||
SybilLog.debug(SybilLog.app, "Refreshing search \(searchID)")
|
SybilLog.debug(SybilLog.app, "Refreshing search \(searchID)")
|
||||||
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
|
selectedChat = nil
|
||||||
|
|
||||||
case .settings:
|
case .settings:
|
||||||
@@ -833,20 +1003,24 @@ final class SybilViewModel {
|
|||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
} catch {
|
} catch {
|
||||||
if isCancellation(error) {
|
if isCancellation(error) {
|
||||||
SybilLog.debug(SybilLog.app, "Selection refresh cancelled for \(selectedItem.id)")
|
SybilLog.debug(SybilLog.app, "Selection refresh cancelled for \(target.id)")
|
||||||
} else if shouldSuppressInactiveTransportError(error) {
|
} else if shouldSuppressInactiveTransportError(error) {
|
||||||
SybilLog.info(SybilLog.app, "Suppressing selection refresh transport interruption while app is inactive")
|
SybilLog.info(SybilLog.app, "Suppressing selection refresh transport interruption while app is inactive")
|
||||||
} else {
|
} else if selectedItem == target, draftKind == nil {
|
||||||
errorMessage = normalizeAPIError(error)
|
errorMessage = normalizeAPIError(error)
|
||||||
SybilLog.error(SybilLog.app, "Selection refresh failed", error: error)
|
SybilLog.error(SybilLog.app, "Selection refresh failed", error: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
isLoadingSelection = false
|
if selectedItem == target, draftKind == nil {
|
||||||
|
isLoadingSelection = false
|
||||||
|
selectionTask = nil
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedChat = nil
|
selectedChat = nil
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
|
isLoadingSelection = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendChat(content: String, attachments: [ChatAttachment]) async throws {
|
private func sendChat(content: String, attachments: [ChatAttachment]) async throws {
|
||||||
@@ -869,7 +1043,7 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
pendingChatState = PendingChatState(
|
pendingChatState = PendingChatState(
|
||||||
chatID: currentChatID,
|
chatID: currentChatID,
|
||||||
messages: (selectedChat?.messages ?? []) + [optimisticUser, optimisticAssistant]
|
messages: (currentSelectedChat?.messages ?? []) + [optimisticUser, optimisticAssistant]
|
||||||
)
|
)
|
||||||
|
|
||||||
let client = try client()
|
let client = try client()
|
||||||
@@ -878,24 +1052,29 @@ final class SybilViewModel {
|
|||||||
if chatID == nil {
|
if chatID == nil {
|
||||||
let created = try await client.createChat(title: nil)
|
let created = try await client.createChat(title: nil)
|
||||||
chatID = created.id
|
chatID = created.id
|
||||||
draftKind = nil
|
let shouldShowCreatedChat = activeSendContext.map { isSendContextVisible($0) } ?? true
|
||||||
selectedItem = .chat(created.id)
|
activeSendContext = .chat(created.id)
|
||||||
|
|
||||||
chats.removeAll(where: { $0.id == created.id })
|
chats.removeAll(where: { $0.id == created.id })
|
||||||
chats.insert(created, at: 0)
|
chats.insert(created, at: 0)
|
||||||
|
|
||||||
selectedChat = ChatDetail(
|
if shouldShowCreatedChat {
|
||||||
id: created.id,
|
draftKind = nil
|
||||||
title: created.title,
|
selectedItem = .chat(created.id)
|
||||||
createdAt: created.createdAt,
|
|
||||||
updatedAt: created.updatedAt,
|
selectedChat = ChatDetail(
|
||||||
initiatedProvider: created.initiatedProvider,
|
id: created.id,
|
||||||
initiatedModel: created.initiatedModel,
|
title: created.title,
|
||||||
lastUsedProvider: created.lastUsedProvider,
|
createdAt: created.createdAt,
|
||||||
lastUsedModel: created.lastUsedModel,
|
updatedAt: created.updatedAt,
|
||||||
messages: []
|
initiatedProvider: created.initiatedProvider,
|
||||||
)
|
initiatedModel: created.initiatedModel,
|
||||||
selectedSearch = nil
|
lastUsedProvider: created.lastUsedProvider,
|
||||||
|
lastUsedModel: created.lastUsedModel,
|
||||||
|
messages: []
|
||||||
|
)
|
||||||
|
selectedSearch = nil
|
||||||
|
}
|
||||||
|
|
||||||
SybilLog.info(SybilLog.app, "Created chat \(created.id)")
|
SybilLog.info(SybilLog.app, "Created chat \(created.id)")
|
||||||
}
|
}
|
||||||
@@ -907,7 +1086,7 @@ final class SybilViewModel {
|
|||||||
pendingChatState?.chatID = chatID
|
pendingChatState?.chatID = chatID
|
||||||
|
|
||||||
let baseChat: ChatDetail
|
let baseChat: ChatDetail
|
||||||
if let selectedChat, selectedChat.id == chatID {
|
if let selectedChat = currentSelectedChat, selectedChat.id == chatID {
|
||||||
baseChat = selectedChat
|
baseChat = selectedChat
|
||||||
} else {
|
} else {
|
||||||
baseChat = try await client.getChat(chatID: chatID)
|
baseChat = try await client.getChat(chatID: chatID)
|
||||||
@@ -929,7 +1108,7 @@ final class SybilViewModel {
|
|||||||
let streamLifecycleGeneration = appLifecycleGeneration
|
let streamLifecycleGeneration = appLifecycleGeneration
|
||||||
let streamStartedWhileInactive = !isAppActive
|
let streamStartedWhileInactive = !isAppActive
|
||||||
|
|
||||||
if isUntitledChat(chatID: chatID, detail: selectedChat) {
|
if isUntitledChat(chatID: chatID, detail: currentSelectedChat) {
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
do {
|
do {
|
||||||
@@ -1001,9 +1180,25 @@ final class SybilViewModel {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await refreshCollections(preferredSelection: .chat(chatID))
|
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 {
|
do {
|
||||||
selectedChat = try await client.getChat(chatID: chatID)
|
let refreshedChat = try await client.getChat(chatID: chatID)
|
||||||
|
guard selectedItem == sentChatSelection, draftKind == nil else {
|
||||||
|
pendingChatState = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedChat = refreshedChat
|
||||||
} catch {
|
} catch {
|
||||||
if shouldSuppressLifecycleTransportError(
|
if shouldSuppressLifecycleTransportError(
|
||||||
error,
|
error,
|
||||||
@@ -1057,12 +1252,17 @@ final class SybilViewModel {
|
|||||||
if searchID == nil {
|
if searchID == nil {
|
||||||
let created = try await client.createSearch(title: String(query.prefix(80)), query: query)
|
let created = try await client.createSearch(title: String(query.prefix(80)), query: query)
|
||||||
searchID = created.id
|
searchID = created.id
|
||||||
draftKind = nil
|
let shouldShowCreatedSearch = activeSendContext.map { isSendContextVisible($0) } ?? true
|
||||||
selectedItem = .search(created.id)
|
activeSendContext = .search(created.id)
|
||||||
|
|
||||||
searches.removeAll(where: { $0.id == created.id })
|
searches.removeAll(where: { $0.id == created.id })
|
||||||
searches.insert(created, at: 0)
|
searches.insert(created, at: 0)
|
||||||
|
|
||||||
|
if shouldShowCreatedSearch {
|
||||||
|
draftKind = nil
|
||||||
|
selectedItem = .search(created.id)
|
||||||
|
}
|
||||||
|
|
||||||
SybilLog.info(SybilLog.app, "Created search \(created.id)")
|
SybilLog.info(SybilLog.app, "Created search \(created.id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1071,21 +1271,23 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let now = Date()
|
let now = Date()
|
||||||
selectedSearch = SearchDetail(
|
if selectedItem == .search(searchID), draftKind == nil {
|
||||||
id: searchID,
|
selectedSearch = SearchDetail(
|
||||||
title: String(query.prefix(80)),
|
id: searchID,
|
||||||
query: query,
|
title: String(query.prefix(80)),
|
||||||
createdAt: selectedSearch?.createdAt ?? now,
|
query: query,
|
||||||
updatedAt: now,
|
createdAt: currentSelectedSearch?.createdAt ?? now,
|
||||||
requestId: nil,
|
updatedAt: now,
|
||||||
latencyMs: nil,
|
requestId: nil,
|
||||||
error: nil,
|
latencyMs: nil,
|
||||||
answerText: nil,
|
error: nil,
|
||||||
answerRequestId: nil,
|
answerText: nil,
|
||||||
answerCitations: nil,
|
answerRequestId: nil,
|
||||||
answerError: nil,
|
answerCitations: nil,
|
||||||
results: []
|
answerError: nil,
|
||||||
)
|
results: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
let streamStatus = SearchStreamStatus()
|
let streamStatus = SearchStreamStatus()
|
||||||
let streamLifecycleGeneration = appLifecycleGeneration
|
let streamLifecycleGeneration = appLifecycleGeneration
|
||||||
@@ -1123,7 +1325,12 @@ final class SybilViewModel {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await refreshCollections(preferredSelection: .search(searchID))
|
let sentSearchSelection = SidebarSelection.search(searchID)
|
||||||
|
let shouldKeepSentSearchSelected = selectedItem == sentSearchSelection && draftKind == nil
|
||||||
|
await refreshCollections(
|
||||||
|
preferredSelection: shouldKeepSentSearchSelected ? sentSearchSelection : selectedItem,
|
||||||
|
refreshSelection: false
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applySearchEvent(
|
private func applySearchEvent(
|
||||||
@@ -1131,8 +1338,10 @@ final class SybilViewModel {
|
|||||||
searchID: String,
|
searchID: String,
|
||||||
streamStatus: SearchStreamStatus
|
streamStatus: SearchStreamStatus
|
||||||
) async {
|
) async {
|
||||||
guard let current = selectedSearch, current.id == searchID else {
|
guard let current = currentSelectedSearch, current.id == searchID else {
|
||||||
if case let .done(payload) = event {
|
if case let .done(payload) = event,
|
||||||
|
selectedItem == .search(searchID),
|
||||||
|
draftKind == nil {
|
||||||
selectedSearch = payload.search
|
selectedSearch = payload.search
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -1239,6 +1448,22 @@ final class SybilViewModel {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var currentSelectedChat: ChatDetail? {
|
||||||
|
guard case let .chat(chatID) = selectedItem,
|
||||||
|
selectedChat?.id == chatID else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return selectedChat
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentSelectedSearch: SearchDetail? {
|
||||||
|
guard case let .search(searchID) = selectedItem,
|
||||||
|
selectedSearch?.id == searchID else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return selectedSearch
|
||||||
|
}
|
||||||
|
|
||||||
private var currentSearchID: String? {
|
private var currentSearchID: String? {
|
||||||
if draftKind == .search {
|
if draftKind == .search {
|
||||||
return nil
|
return nil
|
||||||
@@ -1249,6 +1474,33 @@ final class SybilViewModel {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var currentSendContext: ActiveSendContext {
|
||||||
|
if isSearchMode {
|
||||||
|
if let searchID = currentSearchID {
|
||||||
|
return .search(searchID)
|
||||||
|
}
|
||||||
|
return .draftSearch(draftIdentity)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let chatID = currentChatID {
|
||||||
|
return .chat(chatID)
|
||||||
|
}
|
||||||
|
return .draftChat(draftIdentity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isSendContextVisible(_ context: ActiveSendContext) -> Bool {
|
||||||
|
switch context {
|
||||||
|
case let .draftChat(identity):
|
||||||
|
return draftKind == .chat && draftIdentity == identity
|
||||||
|
case let .chat(chatID):
|
||||||
|
return selectedItem == .chat(chatID)
|
||||||
|
case let .draftSearch(identity):
|
||||||
|
return draftKind == .search && draftIdentity == identity
|
||||||
|
case let .search(searchID):
|
||||||
|
return selectedItem == .search(searchID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func hasSelection(_ selection: SidebarSelection, chats: [ChatSummary], searches: [SearchSummary]) -> Bool {
|
private func hasSelection(_ selection: SidebarSelection, chats: [ChatSummary], searches: [SearchSummary]) -> Bool {
|
||||||
switch selection {
|
switch selection {
|
||||||
case let .chat(chatID):
|
case let .chat(chatID):
|
||||||
|
|||||||
@@ -155,9 +155,9 @@ struct SybilWorkspaceView: View {
|
|||||||
SybilSettingsView(viewModel: viewModel)
|
SybilSettingsView(viewModel: viewModel)
|
||||||
} else if viewModel.isSearchMode {
|
} else if viewModel.isSearchMode {
|
||||||
SybilSearchResultsView(
|
SybilSearchResultsView(
|
||||||
search: viewModel.selectedSearch,
|
search: viewModel.displayedSearch,
|
||||||
isLoading: viewModel.isLoadingSelection,
|
isLoading: viewModel.isLoadingSelection,
|
||||||
isRunning: viewModel.isSending,
|
isRunning: viewModel.isRunningVisibleSearch,
|
||||||
isStartingChat: viewModel.isCreatingSearchChat,
|
isStartingChat: viewModel.isCreatingSearchChat,
|
||||||
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
||||||
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0
|
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0
|
||||||
@@ -170,7 +170,7 @@ struct SybilWorkspaceView: View {
|
|||||||
SybilChatTranscriptView(
|
SybilChatTranscriptView(
|
||||||
messages: viewModel.displayedMessages,
|
messages: viewModel.displayedMessages,
|
||||||
isLoading: viewModel.isLoadingSelection,
|
isLoading: viewModel.isLoadingSelection,
|
||||||
isSending: viewModel.isSending,
|
isSending: viewModel.isSendingVisibleChat,
|
||||||
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
||||||
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0
|
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
private let searchesResponse: [SearchSummary]
|
private let searchesResponse: [SearchSummary]
|
||||||
private let chatDetails: [String: ChatDetail]
|
private let chatDetails: [String: ChatDetail]
|
||||||
private let searchDetails: [String: SearchDetail]
|
private let searchDetails: [String: SearchDetail]
|
||||||
|
private let createChatResponse: ChatSummary?
|
||||||
|
|
||||||
private var snapshot = MockClientCallSnapshot()
|
private var snapshot = MockClientCallSnapshot()
|
||||||
|
private var getChatDelayNanoseconds: UInt64 = 0
|
||||||
|
private var getSearchDelayNanoseconds: UInt64 = 0
|
||||||
private var completionStreamNetworkErrorMessage: String?
|
private var completionStreamNetworkErrorMessage: String?
|
||||||
private var completionStreamDelayNanoseconds: UInt64 = 0
|
private var completionStreamDelayNanoseconds: UInt64 = 0
|
||||||
private var searchStreamNetworkErrorMessage: String?
|
private var searchStreamNetworkErrorMessage: String?
|
||||||
@@ -28,12 +31,14 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
chatsResponse: [ChatSummary] = [],
|
chatsResponse: [ChatSummary] = [],
|
||||||
searchesResponse: [SearchSummary] = [],
|
searchesResponse: [SearchSummary] = [],
|
||||||
chatDetails: [String: ChatDetail] = [:],
|
chatDetails: [String: ChatDetail] = [:],
|
||||||
searchDetails: [String: SearchDetail] = [:]
|
searchDetails: [String: SearchDetail] = [:],
|
||||||
|
createChatResponse: ChatSummary? = nil
|
||||||
) {
|
) {
|
||||||
self.chatsResponse = chatsResponse
|
self.chatsResponse = chatsResponse
|
||||||
self.searchesResponse = searchesResponse
|
self.searchesResponse = searchesResponse
|
||||||
self.chatDetails = chatDetails
|
self.chatDetails = chatDetails
|
||||||
self.searchDetails = searchDetails
|
self.searchDetails = searchDetails
|
||||||
|
self.createChatResponse = createChatResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
func currentSnapshot() -> MockClientCallSnapshot {
|
func currentSnapshot() -> MockClientCallSnapshot {
|
||||||
@@ -45,6 +50,14 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
completionStreamDelayNanoseconds = delayNanoseconds
|
completionStreamDelayNanoseconds = delayNanoseconds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setGetChatDelay(_ delayNanoseconds: UInt64) {
|
||||||
|
getChatDelayNanoseconds = delayNanoseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
func setGetSearchDelay(_ delayNanoseconds: UInt64) {
|
||||||
|
getSearchDelayNanoseconds = delayNanoseconds
|
||||||
|
}
|
||||||
|
|
||||||
func setSearchStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
|
func setSearchStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
|
||||||
searchStreamNetworkErrorMessage = message
|
searchStreamNetworkErrorMessage = message
|
||||||
searchStreamDelayNanoseconds = delayNanoseconds
|
searchStreamDelayNanoseconds = delayNanoseconds
|
||||||
@@ -60,11 +73,17 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func createChat(title: String?) async throws -> ChatSummary {
|
func createChat(title: String?) async throws -> ChatSummary {
|
||||||
|
if let createChatResponse {
|
||||||
|
return createChatResponse
|
||||||
|
}
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
func getChat(chatID: String) async throws -> ChatDetail {
|
func getChat(chatID: String) async throws -> ChatDetail {
|
||||||
snapshot.getChat += 1
|
snapshot.getChat += 1
|
||||||
|
if getChatDelayNanoseconds > 0 {
|
||||||
|
try await Task.sleep(nanoseconds: getChatDelayNanoseconds)
|
||||||
|
}
|
||||||
guard let detail = chatDetails[chatID] else {
|
guard let detail = chatDetails[chatID] else {
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
@@ -90,6 +109,9 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
|
|
||||||
func getSearch(searchID: String) async throws -> SearchDetail {
|
func getSearch(searchID: String) async throws -> SearchDetail {
|
||||||
snapshot.getSearch += 1
|
snapshot.getSearch += 1
|
||||||
|
if getSearchDelayNanoseconds > 0 {
|
||||||
|
try await Task.sleep(nanoseconds: getSearchDelayNanoseconds)
|
||||||
|
}
|
||||||
guard let detail = searchDetails[searchID] else {
|
guard let detail = searchDetails[searchID] else {
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
@@ -293,6 +315,100 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
#expect(viewModel.selectedSearch?.answerText == "fresh answer")
|
#expect(viewModel.selectedSearch?.answerText == "fresh answer")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func selectingChatClearsStaleTranscriptUntilNewDetailLoads() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_210)
|
||||||
|
let staleDetail = makeChatDetail(id: "chat-old", date: date, body: "stale transcript")
|
||||||
|
let freshDetail = makeChatDetail(id: "chat-new", date: date, body: "fresh transcript")
|
||||||
|
let client = MockSybilClient(chatDetails: ["chat-new": freshDetail])
|
||||||
|
await client.setGetChatDelay(50_000_000)
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
viewModel.selectedItem = .chat("chat-old")
|
||||||
|
viewModel.selectedChat = staleDetail
|
||||||
|
|
||||||
|
viewModel.select(.chat("chat-new"))
|
||||||
|
|
||||||
|
#expect(viewModel.displayedMessages.isEmpty)
|
||||||
|
#expect(viewModel.isLoadingSelection)
|
||||||
|
|
||||||
|
try await Task.sleep(nanoseconds: 90_000_000)
|
||||||
|
|
||||||
|
#expect(viewModel.displayedMessages.first?.content == "fresh transcript")
|
||||||
|
#expect(!viewModel.isLoadingSelection)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func navigationSelectionWaitsForFastTranscriptLoad() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_220)
|
||||||
|
let detail = makeChatDetail(id: "chat-fast", date: date, body: "loaded before push")
|
||||||
|
let client = MockSybilClient(chatDetails: ["chat-fast": detail])
|
||||||
|
await client.setGetChatDelay(20_000_000)
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
|
||||||
|
await viewModel.selectForNavigation(.chat("chat-fast"), preloadTimeout: .milliseconds(500))
|
||||||
|
|
||||||
|
#expect(viewModel.selectedItem == .chat("chat-fast"))
|
||||||
|
#expect(viewModel.displayedMessages.first?.content == "loaded before push")
|
||||||
|
#expect(!viewModel.isLoadingSelection)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func navigationSelectionTimesOutAndKeepsLoadingTranscript() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_230)
|
||||||
|
let detail = makeChatDetail(id: "chat-slow", date: date, body: "loaded after push")
|
||||||
|
let client = MockSybilClient(chatDetails: ["chat-slow": detail])
|
||||||
|
await client.setGetChatDelay(100_000_000)
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
|
||||||
|
await viewModel.selectForNavigation(.chat("chat-slow"), preloadTimeout: .milliseconds(10))
|
||||||
|
|
||||||
|
#expect(viewModel.selectedItem == .chat("chat-slow"))
|
||||||
|
#expect(viewModel.displayedMessages.isEmpty)
|
||||||
|
#expect(viewModel.isLoadingSelection)
|
||||||
|
|
||||||
|
try await Task.sleep(nanoseconds: 150_000_000)
|
||||||
|
|
||||||
|
#expect(viewModel.displayedMessages.first?.content == "loaded after push")
|
||||||
|
#expect(!viewModel.isLoadingSelection)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func newDraftChatDoesNotShowTypingStateFromPreviousSend() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_240)
|
||||||
|
let detail = makeChatDetail(id: "chat-typing", date: date, body: "existing transcript")
|
||||||
|
let client = MockSybilClient(chatDetails: ["chat-typing": detail])
|
||||||
|
await client.setCompletionStreamNetworkError(
|
||||||
|
"Network error -1005 while requesting POST: The network connection was lost.",
|
||||||
|
delayNanoseconds: 50_000_000
|
||||||
|
)
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
viewModel.selectedItem = .chat("chat-typing")
|
||||||
|
viewModel.selectedChat = detail
|
||||||
|
viewModel.composer = "continue"
|
||||||
|
|
||||||
|
let sendTask = Task {
|
||||||
|
await viewModel.sendComposer()
|
||||||
|
}
|
||||||
|
try await Task.sleep(nanoseconds: 10_000_000)
|
||||||
|
|
||||||
|
#expect(viewModel.isSendingVisibleChat)
|
||||||
|
|
||||||
|
viewModel.startNewChat()
|
||||||
|
|
||||||
|
#expect(viewModel.displayedMessages.isEmpty)
|
||||||
|
#expect(!viewModel.isSendingVisibleChat)
|
||||||
|
|
||||||
|
await sendTask.value
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func backgroundChatStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws {
|
@Test func backgroundChatStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws {
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_300)
|
let date = Date(timeIntervalSince1970: 1_700_000_300)
|
||||||
|
|||||||
Reference in New Issue
Block a user