This commit is contained in:
@@ -74,6 +74,16 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
return response.chat
|
return response.chat
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func forkChat(chatID: String, messageID: String? = nil) async throws -> ChatSummary {
|
||||||
|
let response = try await request(
|
||||||
|
"/v1/chats/\(chatID)/fork",
|
||||||
|
method: "POST",
|
||||||
|
body: AnyEncodable(ChatForkBody(messageId: messageID)),
|
||||||
|
responseType: ChatCreateResponse.self
|
||||||
|
)
|
||||||
|
return response.chat
|
||||||
|
}
|
||||||
|
|
||||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
|
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
|
||||||
let response = try await request(
|
let response = try await request(
|
||||||
"/v1/chats/\(chatID)",
|
"/v1/chats/\(chatID)",
|
||||||
@@ -704,6 +714,10 @@ private struct ChatTitleUpdateBody: Encodable {
|
|||||||
var title: String
|
var title: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct ChatForkBody: Encodable {
|
||||||
|
var messageId: String?
|
||||||
|
}
|
||||||
|
|
||||||
private struct StarUpdateBody: Encodable {
|
private struct StarUpdateBody: Encodable {
|
||||||
var starred: Bool
|
var starred: Bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ protocol SybilAPIClienting: Sendable {
|
|||||||
messages: [CompletionRequestMessage]?
|
messages: [CompletionRequestMessage]?
|
||||||
) async throws -> ChatSummary
|
) async throws -> ChatSummary
|
||||||
func getChat(chatID: String) async throws -> ChatDetail
|
func getChat(chatID: String) async throws -> ChatDetail
|
||||||
|
func forkChat(chatID: String, messageID: String?) async throws -> ChatSummary
|
||||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary
|
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary
|
||||||
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary
|
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary
|
||||||
func deleteChat(chatID: String) async throws
|
func deleteChat(chatID: String) async throws
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import MarkdownUI
|
import MarkdownUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
struct SybilChatTranscriptView: View {
|
struct SybilChatTranscriptView: View {
|
||||||
var messages: [Message]
|
var messages: [Message]
|
||||||
@@ -9,6 +10,8 @@ struct SybilChatTranscriptView: View {
|
|||||||
var topContentInset: CGFloat = 0
|
var topContentInset: CGFloat = 0
|
||||||
var bottomContentInset: CGFloat = 0
|
var bottomContentInset: CGFloat = 0
|
||||||
var bottomPinRequestID: Int = 0
|
var bottomPinRequestID: Int = 0
|
||||||
|
var isForking: Bool = false
|
||||||
|
var onForkResponse: ((String) -> Void)? = nil
|
||||||
|
|
||||||
@State private var hasTrackedToolCallMessages = false
|
@State private var hasTrackedToolCallMessages = false
|
||||||
@State private var knownToolCallMessageIDs: Set<String> = []
|
@State private var knownToolCallMessageIDs: Set<String> = []
|
||||||
@@ -45,7 +48,11 @@ struct SybilChatTranscriptView: View {
|
|||||||
MessageBubble(
|
MessageBubble(
|
||||||
message: message,
|
message: message,
|
||||||
isSending: isSending,
|
isSending: isSending,
|
||||||
streamingAssistantPresentation: streamingAssistantPresentation
|
streamingAssistantPresentation: streamingAssistantPresentation,
|
||||||
|
isForking: isForking,
|
||||||
|
onFork: {
|
||||||
|
onForkResponse?(message.id)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
case let .toolGroup(id, messages):
|
case let .toolGroup(id, messages):
|
||||||
@@ -146,6 +153,8 @@ private struct MessageBubble: View {
|
|||||||
var message: Message
|
var message: Message
|
||||||
var isSending: Bool
|
var isSending: Bool
|
||||||
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
||||||
|
var isForking: Bool
|
||||||
|
var onFork: (() -> Void)?
|
||||||
|
|
||||||
private var toolCallMetadata: ToolCallMetadata? {
|
private var toolCallMetadata: ToolCallMetadata? {
|
||||||
message.toolCallMetadata
|
message.toolCallMetadata
|
||||||
@@ -164,7 +173,37 @@ private struct MessageBubble: View {
|
|||||||
return streamingAssistantPresentation
|
return streamingAssistantPresentation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var canFork: Bool {
|
||||||
|
message.role == .assistant &&
|
||||||
|
!message.id.hasPrefix("temp-") &&
|
||||||
|
onFork != nil
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if canFork {
|
||||||
|
bubbleContent
|
||||||
|
.contextMenu {
|
||||||
|
Button {
|
||||||
|
UIPasteboard.general.string = message.content
|
||||||
|
} label: {
|
||||||
|
Label("Copy", systemImage: "doc.on.doc")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
onFork?()
|
||||||
|
} label: {
|
||||||
|
Label("Fork", systemImage: "arrow.triangle.branch")
|
||||||
|
}
|
||||||
|
.disabled(isForking)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bubbleContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var bubbleContent: some View {
|
||||||
HStack(alignment: .top, spacing: 0) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
leadingSpacer
|
leadingSpacer
|
||||||
|
|
||||||
|
|||||||
@@ -130,7 +130,10 @@ struct SybilPhoneShellView: View {
|
|||||||
route: route,
|
route: route,
|
||||||
onRequestBack: { _ in showSidebarOverlay() },
|
onRequestBack: { _ in showSidebarOverlay() },
|
||||||
onRequestNewChat: sidebarWorkspaceNewChatAction,
|
onRequestNewChat: sidebarWorkspaceNewChatAction,
|
||||||
onShowSidebar: showSidebarOverlay
|
onShowSidebar: showSidebarOverlay,
|
||||||
|
onOpenForkedChat: { chatID in
|
||||||
|
showRoute(.chat(chatID))
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.background(SybilTheme.background)
|
.background(SybilTheme.background)
|
||||||
.blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress))
|
.blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress))
|
||||||
@@ -511,6 +514,9 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
},
|
},
|
||||||
onSelect: { item in
|
onSelect: { item in
|
||||||
onSelect(item.selection)
|
onSelect(item.selection)
|
||||||
|
},
|
||||||
|
onOpenForkedChat: { chatID in
|
||||||
|
onSelect(.chat(chatID))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -585,6 +591,7 @@ private struct SybilPhoneDestinationView: View {
|
|||||||
let onRequestBack: (_ animateNavigation: Bool) -> Void
|
let onRequestBack: (_ animateNavigation: Bool) -> Void
|
||||||
let onRequestNewChat: (() -> Void)?
|
let onRequestNewChat: (() -> Void)?
|
||||||
let onShowSidebar: () -> Void
|
let onShowSidebar: () -> Void
|
||||||
|
let onOpenForkedChat: (String) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
SybilWorkspaceView(
|
SybilWorkspaceView(
|
||||||
@@ -593,7 +600,8 @@ private struct SybilPhoneDestinationView: View {
|
|||||||
navigationLeadingControl: .showSidebar,
|
navigationLeadingControl: .showSidebar,
|
||||||
onShowSidebar: onShowSidebar,
|
onShowSidebar: onShowSidebar,
|
||||||
onRequestBack: onRequestBack,
|
onRequestBack: onRequestBack,
|
||||||
onRequestNewChat: onRequestNewChat
|
onRequestNewChat: onRequestNewChat,
|
||||||
|
onOpenForkedChat: onOpenForkedChat
|
||||||
)
|
)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.task(id: route) {
|
.task(id: route) {
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ struct SybilSidebarItemList: View {
|
|||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
var isSelected: (SidebarItem) -> Bool
|
var isSelected: (SidebarItem) -> Bool
|
||||||
var onSelect: (SidebarItem) -> Void
|
var onSelect: (SidebarItem) -> Void
|
||||||
|
var onOpenForkedChat: ((String) -> Void)? = nil
|
||||||
@State private var renameTarget: SidebarItem?
|
@State private var renameTarget: SidebarItem?
|
||||||
@State private var renameTitle = ""
|
@State private var renameTitle = ""
|
||||||
|
|
||||||
@@ -169,6 +170,13 @@ struct SybilSidebarItemList: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if item.kind == .chat {
|
if item.kind == .chat {
|
||||||
|
Button {
|
||||||
|
forkChat(item)
|
||||||
|
} label: {
|
||||||
|
Label("Fork", systemImage: "arrow.triangle.branch")
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isForkingChat || item.isRunning)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
renameTarget = item
|
renameTarget = item
|
||||||
renameTitle = item.title
|
renameTitle = item.title
|
||||||
@@ -215,6 +223,28 @@ struct SybilSidebarItemList: View {
|
|||||||
.disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
.disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func forkChat(_ item: SidebarItem) {
|
||||||
|
guard case let .chat(chatID) = item.selection else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectionAtStart = viewModel.selectedItem
|
||||||
|
let draftKindAtStart = viewModel.draftKind
|
||||||
|
Task {
|
||||||
|
guard let forkedChat = await viewModel.forkChat(chatID: chatID),
|
||||||
|
viewModel.selectedItem == selectionAtStart,
|
||||||
|
viewModel.draftKind == draftKindAtStart else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let onOpenForkedChat {
|
||||||
|
onOpenForkedChat(forkedChat.id)
|
||||||
|
} else {
|
||||||
|
viewModel.select(.chat(forkedChat.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SybilSidebarRow: View {
|
struct SybilSidebarRow: View {
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ final class SybilViewModel {
|
|||||||
var isLoadingCollections = false
|
var isLoadingCollections = false
|
||||||
var isLoadingSelection = false
|
var isLoadingSelection = false
|
||||||
var isCreatingSearchChat = false
|
var isCreatingSearchChat = false
|
||||||
|
var isForkingChat = false
|
||||||
var chatBottomPinRequestID = 0
|
var chatBottomPinRequestID = 0
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
|
|
||||||
@@ -958,6 +959,36 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func forkChat(chatID: String, messageID: String? = nil) async -> ChatSummary? {
|
||||||
|
guard isAuthenticated, !isForkingChat else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard messageID != nil || !isChatRowRunning(chatID) else {
|
||||||
|
errorMessage = "Wait for the response to finish before forking this chat."
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let forkDescription = messageID.map { "chat \(chatID) from response \($0)" } ?? "chat \(chatID)"
|
||||||
|
SybilLog.info(SybilLog.ui, "Forking \(forkDescription)")
|
||||||
|
errorMessage = nil
|
||||||
|
isForkingChat = true
|
||||||
|
defer {
|
||||||
|
isForkingChat = false
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let forkedChat = try await client().forkChat(chatID: chatID, messageID: messageID)
|
||||||
|
applyChatSummary(forkedChat, moveToFront: true)
|
||||||
|
return forkedChat
|
||||||
|
} catch {
|
||||||
|
errorMessage = normalizeAPIError(error)
|
||||||
|
SybilLog.error(SybilLog.ui, "Fork failed", error: error)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func renameChat(chatID: String, title: String) async {
|
func renameChat(chatID: String, title: String) async {
|
||||||
guard isAuthenticated else {
|
guard isAuthenticated else {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ struct SybilWorkspaceView: View {
|
|||||||
var onShowSidebar: (() -> Void)? = nil
|
var onShowSidebar: (() -> Void)? = nil
|
||||||
var onRequestBack: ((_ animateNavigation: Bool) -> Void)? = nil
|
var onRequestBack: ((_ animateNavigation: Bool) -> Void)? = nil
|
||||||
var onRequestNewChat: (() -> Void)? = nil
|
var onRequestNewChat: (() -> Void)? = nil
|
||||||
|
var onOpenForkedChat: ((String) -> Void)? = nil
|
||||||
@FocusState private var composerFocused: Bool
|
@FocusState private var composerFocused: Bool
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var isShowingAttachmentOptions = false
|
@State private var isShowingAttachmentOptions = false
|
||||||
@@ -196,7 +197,9 @@ struct SybilWorkspaceView: View {
|
|||||||
streamingAssistantPresentation: viewModel.displayedStreamingAssistantPresentation,
|
streamingAssistantPresentation: viewModel.displayedStreamingAssistantPresentation,
|
||||||
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
||||||
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
|
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
|
||||||
bottomPinRequestID: viewModel.chatBottomPinRequestID
|
bottomPinRequestID: viewModel.chatBottomPinRequestID,
|
||||||
|
isForking: viewModel.isForkingChat,
|
||||||
|
onForkResponse: forkResponse
|
||||||
)
|
)
|
||||||
.id(transcriptScrollContextID)
|
.id(transcriptScrollContextID)
|
||||||
}
|
}
|
||||||
@@ -230,6 +233,28 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func forkResponse(messageID: String) {
|
||||||
|
guard viewModel.draftKind == nil,
|
||||||
|
case let .chat(chatID) = viewModel.selectedItem else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectionAtStart = viewModel.selectedItem
|
||||||
|
Task {
|
||||||
|
guard let forkedChat = await viewModel.forkChat(chatID: chatID, messageID: messageID),
|
||||||
|
viewModel.selectedItem == selectionAtStart,
|
||||||
|
viewModel.draftKind == nil else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let onOpenForkedChat {
|
||||||
|
onOpenForkedChat(forkedChat.id)
|
||||||
|
} else {
|
||||||
|
viewModel.select(.chat(forkedChat.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var customWorkspaceNavigationBar: some View {
|
private var customWorkspaceNavigationBar: some View {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
workspaceNavigationLeadingControl
|
workspaceNavigationLeadingControl
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ private struct MockClientCallSnapshot: Sendable {
|
|||||||
var listSearches = 0
|
var listSearches = 0
|
||||||
var createChat = 0
|
var createChat = 0
|
||||||
var getChat = 0
|
var getChat = 0
|
||||||
|
var forkChat = 0
|
||||||
|
var lastForkChatID: String?
|
||||||
|
var lastForkMessageID: String?
|
||||||
var updateChatTitle = 0
|
var updateChatTitle = 0
|
||||||
var suggestChatTitle = 0
|
var suggestChatTitle = 0
|
||||||
var updateChatStar = 0
|
var updateChatStar = 0
|
||||||
@@ -43,6 +46,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
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 let createChatResponse: ChatSummary?
|
||||||
|
private let forkChatResponse: ChatSummary?
|
||||||
private let updateChatTitleResponses: [String: ChatSummary]
|
private let updateChatTitleResponses: [String: ChatSummary]
|
||||||
private let suggestChatTitleResponses: [String: ChatSummary]
|
private let suggestChatTitleResponses: [String: ChatSummary]
|
||||||
private let updateChatStarResponses: [String: ChatSummary]
|
private let updateChatStarResponses: [String: ChatSummary]
|
||||||
@@ -74,6 +78,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
chatDetails: [String: ChatDetail] = [:],
|
chatDetails: [String: ChatDetail] = [:],
|
||||||
searchDetails: [String: SearchDetail] = [:],
|
searchDetails: [String: SearchDetail] = [:],
|
||||||
createChatResponse: ChatSummary? = nil,
|
createChatResponse: ChatSummary? = nil,
|
||||||
|
forkChatResponse: ChatSummary? = nil,
|
||||||
updateChatTitleResponses: [String: ChatSummary] = [:],
|
updateChatTitleResponses: [String: ChatSummary] = [:],
|
||||||
suggestChatTitleResponses: [String: ChatSummary] = [:],
|
suggestChatTitleResponses: [String: ChatSummary] = [:],
|
||||||
updateChatStarResponses: [String: ChatSummary] = [:],
|
updateChatStarResponses: [String: ChatSummary] = [:],
|
||||||
@@ -87,6 +92,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
self.chatDetails = chatDetails
|
self.chatDetails = chatDetails
|
||||||
self.searchDetails = searchDetails
|
self.searchDetails = searchDetails
|
||||||
self.createChatResponse = createChatResponse
|
self.createChatResponse = createChatResponse
|
||||||
|
self.forkChatResponse = forkChatResponse
|
||||||
self.updateChatTitleResponses = updateChatTitleResponses
|
self.updateChatTitleResponses = updateChatTitleResponses
|
||||||
self.suggestChatTitleResponses = suggestChatTitleResponses
|
self.suggestChatTitleResponses = suggestChatTitleResponses
|
||||||
self.updateChatStarResponses = updateChatStarResponses
|
self.updateChatStarResponses = updateChatStarResponses
|
||||||
@@ -220,6 +226,16 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
return detail
|
return detail
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func forkChat(chatID: String, messageID: String?) async throws -> ChatSummary {
|
||||||
|
snapshot.forkChat += 1
|
||||||
|
snapshot.lastForkChatID = chatID
|
||||||
|
snapshot.lastForkMessageID = messageID
|
||||||
|
guard let forkChatResponse else {
|
||||||
|
throw UnexpectedClientCall()
|
||||||
|
}
|
||||||
|
return forkChatResponse
|
||||||
|
}
|
||||||
|
|
||||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
|
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
|
||||||
snapshot.updateChatTitle += 1
|
snapshot.updateChatTitle += 1
|
||||||
guard let summary = updateChatTitleResponses[chatID] else {
|
guard let summary = updateChatTitleResponses[chatID] else {
|
||||||
@@ -775,6 +791,63 @@ private func makeCompletionToolCall(
|
|||||||
#expect(viewModel.errorMessage == nil)
|
#expect(viewModel.errorMessage == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func wholeChatForkAddsReturnedChatToWorkspace() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_160)
|
||||||
|
let source = makeChatSummary(id: "chat-source", date: date)
|
||||||
|
var forked = makeChatSummary(id: "chat-fork", date: date.addingTimeInterval(10))
|
||||||
|
forked.title = "Fork of Chat chat-source"
|
||||||
|
forked.parentChatId = source.id
|
||||||
|
forked.titleGenerationPending = true
|
||||||
|
let client = MockSybilClient(forkChatResponse: forked)
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
viewModel.chats = [source]
|
||||||
|
viewModel.workspaceItems = [WorkspaceItem(chat: source)]
|
||||||
|
viewModel.selectedItem = .chat(source.id)
|
||||||
|
|
||||||
|
let result = await viewModel.forkChat(chatID: source.id)
|
||||||
|
|
||||||
|
let snapshot = await client.currentSnapshot()
|
||||||
|
#expect(snapshot.forkChat == 1)
|
||||||
|
#expect(snapshot.lastForkChatID == source.id)
|
||||||
|
#expect(snapshot.lastForkMessageID == nil)
|
||||||
|
#expect(result == forked)
|
||||||
|
#expect(viewModel.chats.first == forked)
|
||||||
|
#expect(viewModel.workspaceItems.first == WorkspaceItem(chat: forked))
|
||||||
|
#expect(viewModel.selectedItem == .chat(source.id))
|
||||||
|
#expect(!viewModel.isForkingChat)
|
||||||
|
#expect(viewModel.errorMessage == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func responseForkPassesAssistantMessageID() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_165)
|
||||||
|
let source = makeChatSummary(id: "chat-source", date: date)
|
||||||
|
var forked = makeChatSummary(id: "response-fork", date: date.addingTimeInterval(10))
|
||||||
|
forked.title = "Fork of 'A useful response'"
|
||||||
|
forked.parentChatId = source.id
|
||||||
|
forked.titleGenerationPending = true
|
||||||
|
let client = MockSybilClient(forkChatResponse: forked)
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
viewModel.chats = [source]
|
||||||
|
viewModel.workspaceItems = [WorkspaceItem(chat: source)]
|
||||||
|
|
||||||
|
let result = await viewModel.forkChat(chatID: source.id, messageID: "assistant-message")
|
||||||
|
|
||||||
|
let snapshot = await client.currentSnapshot()
|
||||||
|
#expect(snapshot.forkChat == 1)
|
||||||
|
#expect(snapshot.lastForkChatID == source.id)
|
||||||
|
#expect(snapshot.lastForkMessageID == "assistant-message")
|
||||||
|
#expect(result == forked)
|
||||||
|
#expect(viewModel.sidebarItems.contains(where: { $0.selection == .chat(forked.id) }))
|
||||||
|
#expect(!viewModel.isForkingChat)
|
||||||
|
#expect(viewModel.errorMessage == nil)
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func starringItemsUpdatesSidebarState() async throws {
|
@Test func starringItemsUpdatesSidebarState() async throws {
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_175)
|
let date = Date(timeIntervalSince1970: 1_700_000_175)
|
||||||
|
|||||||
Reference in New Issue
Block a user