diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift index 50b3f7d..f483b59 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift @@ -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 } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift index 75a81d4..cbb48d0 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift @@ -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 diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift index 2541b06..5ecfbce 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift @@ -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 = [] @@ -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 diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift index adfc540..58b8961 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift @@ -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) { diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift index 1348d18..49ad22a 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift @@ -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 { diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift index b71ea2f..947693f 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift @@ -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 diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift index 2c2194a..5836319 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift @@ -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 diff --git a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift index 2ae2f76..abbab3a 100644 --- a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift +++ b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift @@ -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)