This commit is contained in:
@@ -74,6 +74,16 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
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 {
|
||||
let response = try await request(
|
||||
"/v1/chats/\(chatID)",
|
||||
@@ -704,6 +714,10 @@ private struct ChatTitleUpdateBody: Encodable {
|
||||
var title: String
|
||||
}
|
||||
|
||||
private struct ChatForkBody: Encodable {
|
||||
var messageId: String?
|
||||
}
|
||||
|
||||
private struct StarUpdateBody: Encodable {
|
||||
var starred: Bool
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ protocol SybilAPIClienting: Sendable {
|
||||
messages: [CompletionRequestMessage]?
|
||||
) async throws -> ChatSummary
|
||||
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 updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary
|
||||
func deleteChat(chatID: String) async throws
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import MarkdownUI
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct SybilChatTranscriptView: View {
|
||||
var messages: [Message]
|
||||
@@ -9,6 +10,8 @@ struct SybilChatTranscriptView: View {
|
||||
var topContentInset: CGFloat = 0
|
||||
var bottomContentInset: CGFloat = 0
|
||||
var bottomPinRequestID: Int = 0
|
||||
var isForking: Bool = false
|
||||
var onForkResponse: ((String) -> Void)? = nil
|
||||
|
||||
@State private var hasTrackedToolCallMessages = false
|
||||
@State private var knownToolCallMessageIDs: Set<String> = []
|
||||
@@ -45,7 +48,11 @@ struct SybilChatTranscriptView: View {
|
||||
MessageBubble(
|
||||
message: message,
|
||||
isSending: isSending,
|
||||
streamingAssistantPresentation: streamingAssistantPresentation
|
||||
streamingAssistantPresentation: streamingAssistantPresentation,
|
||||
isForking: isForking,
|
||||
onFork: {
|
||||
onForkResponse?(message.id)
|
||||
}
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
case let .toolGroup(id, messages):
|
||||
@@ -146,6 +153,8 @@ private struct MessageBubble: View {
|
||||
var message: Message
|
||||
var isSending: Bool
|
||||
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
||||
var isForking: Bool
|
||||
var onFork: (() -> Void)?
|
||||
|
||||
private var toolCallMetadata: ToolCallMetadata? {
|
||||
message.toolCallMetadata
|
||||
@@ -164,7 +173,37 @@ private struct MessageBubble: View {
|
||||
return streamingAssistantPresentation
|
||||
}
|
||||
|
||||
private var canFork: Bool {
|
||||
message.role == .assistant &&
|
||||
!message.id.hasPrefix("temp-") &&
|
||||
onFork != nil
|
||||
}
|
||||
|
||||
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) {
|
||||
leadingSpacer
|
||||
|
||||
|
||||
@@ -130,7 +130,10 @@ struct SybilPhoneShellView: View {
|
||||
route: route,
|
||||
onRequestBack: { _ in showSidebarOverlay() },
|
||||
onRequestNewChat: sidebarWorkspaceNewChatAction,
|
||||
onShowSidebar: showSidebarOverlay
|
||||
onShowSidebar: showSidebarOverlay,
|
||||
onOpenForkedChat: { chatID in
|
||||
showRoute(.chat(chatID))
|
||||
}
|
||||
)
|
||||
.background(SybilTheme.background)
|
||||
.blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress))
|
||||
@@ -511,6 +514,9 @@ private struct SybilPhoneSidebarRoot: View {
|
||||
},
|
||||
onSelect: { item in
|
||||
onSelect(item.selection)
|
||||
},
|
||||
onOpenForkedChat: { chatID in
|
||||
onSelect(.chat(chatID))
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -585,6 +591,7 @@ private struct SybilPhoneDestinationView: View {
|
||||
let onRequestBack: (_ animateNavigation: Bool) -> Void
|
||||
let onRequestNewChat: (() -> Void)?
|
||||
let onShowSidebar: () -> Void
|
||||
let onOpenForkedChat: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
SybilWorkspaceView(
|
||||
@@ -593,7 +600,8 @@ private struct SybilPhoneDestinationView: View {
|
||||
navigationLeadingControl: .showSidebar,
|
||||
onShowSidebar: onShowSidebar,
|
||||
onRequestBack: onRequestBack,
|
||||
onRequestNewChat: onRequestNewChat
|
||||
onRequestNewChat: onRequestNewChat,
|
||||
onOpenForkedChat: onOpenForkedChat
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.task(id: route) {
|
||||
|
||||
@@ -111,6 +111,7 @@ struct SybilSidebarItemList: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
var isSelected: (SidebarItem) -> Bool
|
||||
var onSelect: (SidebarItem) -> Void
|
||||
var onOpenForkedChat: ((String) -> Void)? = nil
|
||||
@State private var renameTarget: SidebarItem?
|
||||
@State private var renameTitle = ""
|
||||
|
||||
@@ -169,6 +170,13 @@ struct SybilSidebarItemList: View {
|
||||
}
|
||||
|
||||
if item.kind == .chat {
|
||||
Button {
|
||||
forkChat(item)
|
||||
} label: {
|
||||
Label("Fork", systemImage: "arrow.triangle.branch")
|
||||
}
|
||||
.disabled(viewModel.isForkingChat || item.isRunning)
|
||||
|
||||
Button {
|
||||
renameTarget = item
|
||||
renameTitle = item.title
|
||||
@@ -215,6 +223,28 @@ struct SybilSidebarItemList: View {
|
||||
.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 {
|
||||
|
||||
@@ -170,6 +170,7 @@ final class SybilViewModel {
|
||||
var isLoadingCollections = false
|
||||
var isLoadingSelection = false
|
||||
var isCreatingSearchChat = false
|
||||
var isForkingChat = false
|
||||
var chatBottomPinRequestID = 0
|
||||
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 {
|
||||
guard isAuthenticated else {
|
||||
return
|
||||
|
||||
@@ -19,6 +19,7 @@ struct SybilWorkspaceView: View {
|
||||
var onShowSidebar: (() -> Void)? = nil
|
||||
var onRequestBack: ((_ animateNavigation: Bool) -> Void)? = nil
|
||||
var onRequestNewChat: (() -> Void)? = nil
|
||||
var onOpenForkedChat: ((String) -> Void)? = nil
|
||||
@FocusState private var composerFocused: Bool
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var isShowingAttachmentOptions = false
|
||||
@@ -196,7 +197,9 @@ struct SybilWorkspaceView: View {
|
||||
streamingAssistantPresentation: viewModel.displayedStreamingAssistantPresentation,
|
||||
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
||||
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
|
||||
bottomPinRequestID: viewModel.chatBottomPinRequestID
|
||||
bottomPinRequestID: viewModel.chatBottomPinRequestID,
|
||||
isForking: viewModel.isForkingChat,
|
||||
onForkResponse: forkResponse
|
||||
)
|
||||
.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 {
|
||||
HStack(spacing: 14) {
|
||||
workspaceNavigationLeadingControl
|
||||
|
||||
@@ -13,6 +13,9 @@ private struct MockClientCallSnapshot: Sendable {
|
||||
var listSearches = 0
|
||||
var createChat = 0
|
||||
var getChat = 0
|
||||
var forkChat = 0
|
||||
var lastForkChatID: String?
|
||||
var lastForkMessageID: String?
|
||||
var updateChatTitle = 0
|
||||
var suggestChatTitle = 0
|
||||
var updateChatStar = 0
|
||||
@@ -43,6 +46,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
private let chatDetails: [String: ChatDetail]
|
||||
private let searchDetails: [String: SearchDetail]
|
||||
private let createChatResponse: ChatSummary?
|
||||
private let forkChatResponse: ChatSummary?
|
||||
private let updateChatTitleResponses: [String: ChatSummary]
|
||||
private let suggestChatTitleResponses: [String: ChatSummary]
|
||||
private let updateChatStarResponses: [String: ChatSummary]
|
||||
@@ -74,6 +78,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
chatDetails: [String: ChatDetail] = [:],
|
||||
searchDetails: [String: SearchDetail] = [:],
|
||||
createChatResponse: ChatSummary? = nil,
|
||||
forkChatResponse: ChatSummary? = nil,
|
||||
updateChatTitleResponses: [String: ChatSummary] = [:],
|
||||
suggestChatTitleResponses: [String: ChatSummary] = [:],
|
||||
updateChatStarResponses: [String: ChatSummary] = [:],
|
||||
@@ -87,6 +92,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
self.chatDetails = chatDetails
|
||||
self.searchDetails = searchDetails
|
||||
self.createChatResponse = createChatResponse
|
||||
self.forkChatResponse = forkChatResponse
|
||||
self.updateChatTitleResponses = updateChatTitleResponses
|
||||
self.suggestChatTitleResponses = suggestChatTitleResponses
|
||||
self.updateChatStarResponses = updateChatStarResponses
|
||||
@@ -220,6 +226,16 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
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 {
|
||||
snapshot.updateChatTitle += 1
|
||||
guard let summary = updateChatTitleResponses[chatID] else {
|
||||
@@ -775,6 +791,63 @@ private func makeCompletionToolCall(
|
||||
#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
|
||||
@Test func starringItemsUpdatesSidebarState() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_175)
|
||||
|
||||
Reference in New Issue
Block a user