Add chat thread forking
TestFlight / Build and upload (push) Successful in 1m52s

This commit is contained in:
2026-08-16 17:42:39 -07:00
parent 42022bf055
commit eb2b0d3ca0
17 changed files with 1410 additions and 244 deletions
@@ -154,6 +154,8 @@ public struct ChatAttachment: Codable, Hashable, Identifiable, Sendable {
public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
public var id: String
public var title: String?
public var parentChatId: String? = nil
public var titleGenerationPending = false
public var createdAt: Date
public var updatedAt: Date
public var starred = false
@@ -164,6 +166,39 @@ public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
public var lastUsedModel: String?
}
extension ChatSummary {
private enum CodingKeys: String, CodingKey {
case id
case title
case parentChatId
case titleGenerationPending
case createdAt
case updatedAt
case starred
case starredAt
case initiatedProvider
case initiatedModel
case lastUsedProvider
case lastUsedModel
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
title = try container.decodeIfPresent(String.self, forKey: .title)
parentChatId = try container.decodeIfPresent(String.self, forKey: .parentChatId)
titleGenerationPending = try container.decodeIfPresent(Bool.self, forKey: .titleGenerationPending) ?? false
createdAt = try container.decode(Date.self, forKey: .createdAt)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
starred = try container.decodeIfPresent(Bool.self, forKey: .starred) ?? false
starredAt = try container.decodeIfPresent(Date.self, forKey: .starredAt)
initiatedProvider = try container.decodeIfPresent(Provider.self, forKey: .initiatedProvider)
initiatedModel = try container.decodeIfPresent(String.self, forKey: .initiatedModel)
lastUsedProvider = try container.decodeIfPresent(Provider.self, forKey: .lastUsedProvider)
lastUsedModel = try container.decodeIfPresent(String.self, forKey: .lastUsedModel)
}
}
public struct SearchSummary: Codable, Identifiable, Hashable, Sendable {
public var id: String
public var title: String?
@@ -184,6 +219,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
public var id: String
public var title: String?
public var query: String?
public var parentChatId: String? = nil
public var titleGenerationPending = false
public var createdAt: Date
public var updatedAt: Date
public var starred = false
@@ -198,6 +235,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
self.id = chat.id
self.title = chat.title
self.query = nil
self.parentChatId = chat.parentChatId
self.titleGenerationPending = chat.titleGenerationPending
self.createdAt = chat.createdAt
self.updatedAt = chat.updatedAt
self.starred = chat.starred
@@ -213,6 +252,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
self.id = search.id
self.title = search.title
self.query = search.query
self.parentChatId = nil
self.titleGenerationPending = false
self.createdAt = search.createdAt
self.updatedAt = search.updatedAt
self.starred = search.starred
@@ -228,6 +269,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
return ChatSummary(
id: id,
title: title,
parentChatId: parentChatId,
titleGenerationPending: titleGenerationPending,
createdAt: createdAt,
updatedAt: updatedAt,
starred: starred,
@@ -253,6 +296,43 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
}
}
extension WorkspaceItem {
private enum CodingKeys: String, CodingKey {
case type
case id
case title
case query
case parentChatId
case titleGenerationPending
case createdAt
case updatedAt
case starred
case starredAt
case initiatedProvider
case initiatedModel
case lastUsedProvider
case lastUsedModel
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
type = try container.decode(WorkspaceItemType.self, forKey: .type)
id = try container.decode(String.self, forKey: .id)
title = try container.decodeIfPresent(String.self, forKey: .title)
query = try container.decodeIfPresent(String.self, forKey: .query)
parentChatId = try container.decodeIfPresent(String.self, forKey: .parentChatId)
titleGenerationPending = try container.decodeIfPresent(Bool.self, forKey: .titleGenerationPending) ?? false
createdAt = try container.decode(Date.self, forKey: .createdAt)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
starred = try container.decodeIfPresent(Bool.self, forKey: .starred) ?? false
starredAt = try container.decodeIfPresent(Date.self, forKey: .starredAt)
initiatedProvider = try container.decodeIfPresent(Provider.self, forKey: .initiatedProvider)
initiatedModel = try container.decodeIfPresent(String.self, forKey: .initiatedModel)
lastUsedProvider = try container.decodeIfPresent(Provider.self, forKey: .lastUsedProvider)
lastUsedModel = try container.decodeIfPresent(String.self, forKey: .lastUsedModel)
}
}
public struct Message: Codable, Identifiable, Hashable, Sendable {
public var id: String
public var createdAt: Date
@@ -391,6 +471,8 @@ public enum JSONValue: Codable, Hashable, Sendable {
public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
public var id: String
public var title: String?
public var parentChatId: String? = nil
public var titleGenerationPending = false
public var createdAt: Date
public var updatedAt: Date
public var starred = false
@@ -402,6 +484,41 @@ public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
public var messages: [Message]
}
extension ChatDetail {
private enum CodingKeys: String, CodingKey {
case id
case title
case parentChatId
case titleGenerationPending
case createdAt
case updatedAt
case starred
case starredAt
case initiatedProvider
case initiatedModel
case lastUsedProvider
case lastUsedModel
case messages
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
title = try container.decodeIfPresent(String.self, forKey: .title)
parentChatId = try container.decodeIfPresent(String.self, forKey: .parentChatId)
titleGenerationPending = try container.decodeIfPresent(Bool.self, forKey: .titleGenerationPending) ?? false
createdAt = try container.decode(Date.self, forKey: .createdAt)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
starred = try container.decodeIfPresent(Bool.self, forKey: .starred) ?? false
starredAt = try container.decodeIfPresent(Date.self, forKey: .starredAt)
initiatedProvider = try container.decodeIfPresent(Provider.self, forKey: .initiatedProvider)
initiatedModel = try container.decodeIfPresent(String.self, forKey: .initiatedModel)
lastUsedProvider = try container.decodeIfPresent(Provider.self, forKey: .lastUsedProvider)
lastUsedModel = try container.decodeIfPresent(String.self, forKey: .lastUsedModel)
messages = try container.decode([Message].self, forKey: .messages)
}
}
public struct SearchResultItem: Codable, Identifiable, Hashable, Sendable {
public var id: String
public var createdAt: Date
@@ -406,14 +406,17 @@ final class SybilViewModel {
} else {
initiatedLabel = nil
}
let starOwner = item.parentChatId.flatMap { parentChatID in
workspaceItems.first(where: { $0.type == .chat && $0.id == parentChatID })
} ?? item
return SidebarItem(
selection: .chat(item.id),
kind: .chat,
title: chatTitle(title: item.title, messages: nil),
updatedAt: item.updatedAt,
starred: item.starred,
starredAt: item.starredAt,
starred: starOwner.starred,
starredAt: starOwner.starredAt,
initiatedLabel: initiatedLabel,
isRunning: isChatRowRunning(item.id)
)
@@ -687,6 +690,8 @@ final class SybilViewModel {
selectedChat = ChatDetail(
id: chat.id,
title: chat.title,
parentChatId: chat.parentChatId,
titleGenerationPending: chat.titleGenerationPending,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
starred: chat.starred,
@@ -896,7 +901,8 @@ final class SybilViewModel {
let client = try client()
switch selection {
case let .chat(chatID):
let updated = try await client.updateChatStar(chatID: chatID, starred: starred)
let rootChatID = chatFamilyRootID(for: chatID)
let updated = try await client.updateChatStar(chatID: rootChatID, starred: starred)
applyChatSummary(updated, moveToFront: false)
case let .search(searchID):
let updated = try await client.updateSearchStar(searchID: searchID, starred: starred)
@@ -1454,6 +1460,8 @@ final class SybilViewModel {
if selectedChat?.id == chat.id {
selectedChat?.title = chat.title
selectedChat?.parentChatId = chat.parentChatId
selectedChat?.titleGenerationPending = chat.titleGenerationPending
selectedChat?.updatedAt = chat.updatedAt
selectedChat?.starred = chat.starred
selectedChat?.starredAt = chat.starredAt
@@ -1505,6 +1513,19 @@ final class SybilViewModel {
workspaceItems.insert(item, at: 0)
}
private func chatFamilyRootID(for chatID: String) -> String {
if let selectedChat, selectedChat.id == chatID, let parentChatID = selectedChat.parentChatId {
return parentChatID
}
if let parentChatID = chats.first(where: { $0.id == chatID })?.parentChatId {
return parentChatID
}
if let parentChatID = workspaceItems.first(where: { $0.type == .chat && $0.id == chatID })?.parentChatId {
return parentChatID
}
return chatID
}
private func attachToVisibleActiveRunIfNeeded() {
guard draftKind == nil else {
return
@@ -1854,6 +1875,8 @@ final class SybilViewModel {
selectedChat = ChatDetail(
id: created.id,
title: created.title,
parentChatId: created.parentChatId,
titleGenerationPending: created.titleGenerationPending,
createdAt: created.createdAt,
updatedAt: created.updatedAt,
starred: created.starred,
@@ -1912,7 +1935,7 @@ final class SybilViewModel {
let streamLifecycleGeneration = appLifecycleGeneration
let streamStartedWhileInactive = !isAppActive
if isUntitledChat(chatID: chatID, detail: currentSelectedChat) {
if shouldRequestChatTitle(baseChat) {
Task { [weak self] in
guard let self else { return }
do {
@@ -2623,20 +2646,10 @@ final class SybilViewModel {
)
}
private func isUntitledChat(chatID: String, detail: ChatDetail?) -> Bool {
if let detail, detail.id == chatID {
if let title = detail.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
return false
}
private func shouldRequestChatTitle(_ chat: ChatDetail) -> Bool {
if chat.titleGenerationPending {
return true
}
if let summary = chats.first(where: { $0.id == chatID }) {
if let title = summary.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
return false
}
}
return true
return chat.title?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true
}
}
@@ -10,7 +10,10 @@ private struct MockClientCallSnapshot: Sendable {
var createChat = 0
var getChat = 0
var updateChatTitle = 0
var suggestChatTitle = 0
var updateChatStar = 0
var lastUpdateChatStarID: String?
var lastUpdateChatStarred: Bool?
var updateSearchStar = 0
var getSearch = 0
var getActiveRuns = 0
@@ -36,6 +39,7 @@ private actor MockSybilClient: SybilAPIClienting {
private let searchDetails: [String: SearchDetail]
private let createChatResponse: ChatSummary?
private let updateChatTitleResponses: [String: ChatSummary]
private let suggestChatTitleResponses: [String: ChatSummary]
private let updateChatStarResponses: [String: ChatSummary]
private let updateSearchStarResponses: [String: SearchSummary]
private let activeRunsResponse: ActiveRunsResponse
@@ -64,6 +68,7 @@ private actor MockSybilClient: SybilAPIClienting {
searchDetails: [String: SearchDetail] = [:],
createChatResponse: ChatSummary? = nil,
updateChatTitleResponses: [String: ChatSummary] = [:],
suggestChatTitleResponses: [String: ChatSummary] = [:],
updateChatStarResponses: [String: ChatSummary] = [:],
updateSearchStarResponses: [String: SearchSummary] = [:],
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
@@ -76,6 +81,7 @@ private actor MockSybilClient: SybilAPIClienting {
self.searchDetails = searchDetails
self.createChatResponse = createChatResponse
self.updateChatTitleResponses = updateChatTitleResponses
self.suggestChatTitleResponses = suggestChatTitleResponses
self.updateChatStarResponses = updateChatStarResponses
self.updateSearchStarResponses = updateSearchStarResponses
self.activeRunsResponse = activeRunsResponse
@@ -204,6 +210,8 @@ private actor MockSybilClient: SybilAPIClienting {
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary {
snapshot.updateChatStar += 1
snapshot.lastUpdateChatStarID = chatID
snapshot.lastUpdateChatStarred = starred
guard let summary = updateChatStarResponses[chatID] else {
throw UnexpectedClientCall()
}
@@ -215,7 +223,11 @@ private actor MockSybilClient: SybilAPIClienting {
}
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary {
throw UnexpectedClientCall()
snapshot.suggestChatTitle += 1
guard let summary = suggestChatTitleResponses[chatID] else {
throw UnexpectedClientCall()
}
return summary
}
func listSearches() async throws -> [SearchSummary] {
@@ -420,6 +432,46 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
)
}
@Test func chatForkMetadataDecodesBackwardCompatiblyAndSurvivesWorkspaceConversions() throws {
let decoder = JSONDecoder()
let legacySummary = try decoder.decode(
ChatSummary.self,
from: Data(#"{"id":"legacy-chat","title":"Legacy","createdAt":0,"updatedAt":1}"#.utf8)
)
let legacyWorkspaceItem = try decoder.decode(
WorkspaceItem.self,
from: Data(#"{"type":"chat","id":"legacy-chat","title":"Legacy","createdAt":0,"updatedAt":1}"#.utf8)
)
let legacyDetail = try decoder.decode(
ChatDetail.self,
from: Data(#"{"id":"legacy-chat","title":"Legacy","createdAt":0,"updatedAt":1,"messages":[]}"#.utf8)
)
let forkDetail = try decoder.decode(
ChatDetail.self,
from: Data(#"{"id":"fork-chat","title":"Fork of Legacy","parentChatId":"root-chat","titleGenerationPending":true,"createdAt":0,"updatedAt":1,"messages":[]}"#.utf8)
)
#expect(legacySummary.parentChatId == nil)
#expect(!legacySummary.titleGenerationPending)
#expect(legacyWorkspaceItem.parentChatId == nil)
#expect(!legacyWorkspaceItem.titleGenerationPending)
#expect(legacyDetail.parentChatId == nil)
#expect(!legacyDetail.titleGenerationPending)
#expect(forkDetail.parentChatId == "root-chat")
#expect(forkDetail.titleGenerationPending)
var fork = legacySummary
fork.parentChatId = "root-chat"
fork.titleGenerationPending = true
let workspaceItem = WorkspaceItem(chat: fork)
let restoredSummary = try #require(workspaceItem.chatSummary)
#expect(workspaceItem.parentChatId == "root-chat")
#expect(workspaceItem.titleGenerationPending)
#expect(restoredSummary.parentChatId == "root-chat")
#expect(restoredSummary.titleGenerationPending)
}
@Test func transcriptRenderItemsGroupAdjacentToolCalls() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_000)
let user = Message(id: "user-1", createdAt: date, role: .user, content: "Search this", name: nil)
@@ -567,18 +619,16 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
@MainActor
@Test func renameChatUpdatesSidebarAndSelectedTranscriptTitle() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_150)
let original = makeChatSummary(id: "chat-rename", date: date)
let renamed = ChatSummary(
id: "chat-rename",
title: "Renamed chat",
createdAt: date,
updatedAt: date.addingTimeInterval(60),
initiatedProvider: .openai,
initiatedModel: "gpt-4.1-mini",
lastUsedProvider: .openai,
lastUsedModel: "gpt-4.1-mini"
)
let detail = makeChatDetail(id: "chat-rename", date: date, body: "existing transcript")
var original = makeChatSummary(id: "chat-rename", date: date)
original.parentChatId = "root-chat"
original.titleGenerationPending = true
var renamed = original
renamed.title = "Renamed chat"
renamed.titleGenerationPending = false
renamed.updatedAt = date.addingTimeInterval(60)
var detail = makeChatDetail(id: "chat-rename", date: date, body: "existing transcript")
detail.parentChatId = original.parentChatId
detail.titleGenerationPending = original.titleGenerationPending
let client = MockSybilClient(
chatsResponse: [original],
updateChatTitleResponses: ["chat-rename": renamed]
@@ -596,7 +646,13 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
let snapshot = await client.currentSnapshot()
#expect(snapshot.updateChatTitle == 1)
#expect(viewModel.sidebarItems.first?.title == "Renamed chat")
#expect(viewModel.chats.first?.parentChatId == "root-chat")
#expect(viewModel.chats.first?.titleGenerationPending == false)
#expect(viewModel.workspaceItems.first?.parentChatId == "root-chat")
#expect(viewModel.workspaceItems.first?.titleGenerationPending == false)
#expect(viewModel.selectedChat?.title == "Renamed chat")
#expect(viewModel.selectedChat?.parentChatId == "root-chat")
#expect(viewModel.selectedChat?.titleGenerationPending == false)
#expect(viewModel.errorMessage == nil)
}
@@ -635,6 +691,54 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
#expect(viewModel.sidebarItems.first(where: { $0.selection == .search("search-star") })?.starred == true)
}
@MainActor
@Test func unstarringForkTargetsRootWithoutReplacingSelectedChild() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_180)
var root = makeChatSummary(id: "chat-root", date: date)
root.starred = true
root.starredAt = date.addingTimeInterval(5)
var child = makeChatSummary(id: "chat-child", date: date.addingTimeInterval(1))
child.parentChatId = root.id
child.titleGenerationPending = true
var childDetail = makeChatDetail(id: child.id, date: date, body: "forked transcript")
childDetail.title = child.title
childDetail.parentChatId = root.id
childDetail.titleGenerationPending = true
var unstarredRoot = root
unstarredRoot.starred = false
unstarredRoot.starredAt = nil
let client = MockSybilClient(
chatsResponse: [root, child],
updateChatStarResponses: [root.id: unstarredRoot]
)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.chats = [root, child]
viewModel.workspaceItems = [WorkspaceItem(chat: root), WorkspaceItem(chat: child)]
viewModel.selectedItem = .chat(child.id)
viewModel.selectedChat = childDetail
#expect(viewModel.sidebarItems.first(where: { $0.selection == .chat(child.id) })?.starred == true)
await viewModel.setItemStarred(.chat(child.id), starred: false)
let snapshot = await client.currentSnapshot()
#expect(snapshot.updateChatStar == 1)
#expect(snapshot.lastUpdateChatStarID == root.id)
#expect(snapshot.lastUpdateChatStarred == false)
#expect(viewModel.chats.first(where: { $0.id == root.id })?.starred == false)
#expect(viewModel.chats.first(where: { $0.id == child.id }) == child)
#expect(viewModel.sidebarItems.first(where: { $0.selection == .chat(child.id) })?.starred == false)
#expect(viewModel.selectedItem == .chat(child.id))
#expect(viewModel.selectedChat == childDetail)
#expect(viewModel.errorMessage == nil)
}
@MainActor
@Test func foregroundSearchRefreshReloadsSelectedSearch() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_200)
@@ -781,6 +885,72 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
#expect(viewModel.chatBottomPinRequestID == initialPinRequestID + 1)
}
@MainActor
@Test func firstPromptInPendingForkRequestsAndAppliesGeneratedTitle() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_247)
var fork = makeChatSummary(id: "chat-fork", date: date)
fork.title = "Fork of Original chat"
fork.parentChatId = "root-chat"
fork.titleGenerationPending = true
var forkDetail = makeChatDetail(id: fork.id, date: date, body: "forked transcript")
forkDetail.title = fork.title
forkDetail.parentChatId = fork.parentChatId
forkDetail.titleGenerationPending = true
var titledFork = fork
titledFork.title = "Investigating the follow-up"
titledFork.titleGenerationPending = false
titledFork.updatedAt = date.addingTimeInterval(1)
var titledForkDetail = forkDetail
titledForkDetail.title = titledFork.title
titledForkDetail.titleGenerationPending = false
titledForkDetail.updatedAt = titledFork.updatedAt
let client = MockSybilClient(
chatsResponse: [titledFork],
chatDetails: [fork.id: titledForkDetail],
suggestChatTitleResponses: [fork.id: titledFork]
)
await client.setCompletionStreamEvents(
[.done(CompletionStreamDone(text: "Follow-up answer"))],
delayNanoseconds: 100_000_000
)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.chats = [fork]
viewModel.workspaceItems = [WorkspaceItem(chat: fork)]
viewModel.selectedItem = .chat(fork.id)
viewModel.selectedChat = forkDetail
viewModel.composer = "Investigate this follow-up"
let sendTask = Task {
await viewModel.sendComposer()
}
for _ in 0..<20 {
let snapshot = await client.currentSnapshot()
if snapshot.suggestChatTitle == 1,
viewModel.selectedChat?.title == titledFork.title,
viewModel.selectedChat?.titleGenerationPending == false {
break
}
try await Task.sleep(nanoseconds: 5_000_000)
}
let titleSnapshot = await client.currentSnapshot()
#expect(titleSnapshot.suggestChatTitle == 1)
#expect(viewModel.chats.first?.title == titledFork.title)
#expect(viewModel.workspaceItems.first?.title == titledFork.title)
#expect(viewModel.selectedChat?.title == titledFork.title)
#expect(viewModel.selectedChat?.titleGenerationPending == false)
await sendTask.value
}
@MainActor
@Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
let client = MockSybilClient()