ios: add chat fork actions
TestFlight / Build and upload (push) Successful in 1m39s

This commit is contained in:
2026-09-06 14:03:30 -07:00
parent 04ac39499f
commit 3a9c9bb74a
8 changed files with 225 additions and 4 deletions
@@ -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)