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
}
}