This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user