From eb2b0d3ca0e705971458b317f09e528f1f8a7716 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 16 Aug 2026 17:42:39 -0700 Subject: [PATCH] Add chat thread forking --- docs/api/rest.md | 35 +- docs/api/streaming-chat.md | 2 + .../Sybil/Sources/Sybil/SybilModels.swift | 117 ++++ .../Sybil/Sources/Sybil/SybilViewModel.swift | 47 +- .../Sybil/Tests/SybilTests/SybilTests.swift | 196 ++++++- .../migration.sql | 5 + server/prisma/schema.prisma | 9 +- server/src/routes.ts | 242 ++++++-- server/tests/chat-forks.test.ts | 252 +++++++++ tui/src/index.ts | 61 +- tui/src/types.ts | 4 + web/src/App.tsx | 521 +++++++++++++----- .../components/chat/chat-messages-panel.tsx | 8 +- web/src/lib/api.ts | 12 + web/src/lib/chat-forking.ts | 82 +++ web/tests/chat-forking.test.mjs | 59 ++ web/tsconfig.tsbuildinfo | 2 +- 17 files changed, 1410 insertions(+), 244 deletions(-) create mode 100644 server/prisma/migrations/20260816000000_add_chat_forks/migration.sql create mode 100644 server/tests/chat-forks.test.ts create mode 100644 web/src/lib/chat-forking.ts create mode 100644 web/tests/chat-forking.test.mjs diff --git a/docs/api/rest.md b/docs/api/rest.md index abfe418..9d922cf 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -89,6 +89,8 @@ Behavior notes: "type": "chat", "id": "chat-id", "title": "optional title", + "parentChatId": null, + "titleGenerationPending": false, "createdAt": "2026-02-14T00:00:00.000Z", "updatedAt": "2026-02-14T00:00:00.000Z", "starred": true, @@ -117,7 +119,8 @@ Behavior notes: Behavior notes: - This endpoint is intended for combined conversation/search lists such as sidebars. - The legacy `GET /v1/chats` and `GET /v1/searches` endpoints remain available for clients that need separate collections. -- The response currently combines up to 100 chats and up to 100 searches. +- The response currently combines the 100 most recently updated chats, any additional root chats needed to group those forks, and up to 100 searches. +- Root chats have `parentChatId: null`. Every fork points directly to its single root chat, including a fork created from another fork, so clients can group rows without traversing a fork chain. - `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model. ## Chats @@ -148,15 +151,32 @@ Behavior notes: Behavior notes: - `provider` and `model` must be supplied together when present. +- Newly created non-fork chats have `parentChatId: null` and `titleGenerationPending: false`. - When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`. - `additionalSystemPrompt` is trimmed and stored on the chat; blank values are stored as `null`. - `enabledTools` stores the enabled Sybil-managed tool names for future chat completions. Unknown tool names are ignored; omitted values default to all currently available tools. - Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages. +### `POST /v1/chats/:chatId/fork` +- Body: `{ "messageId"?: string }` +- Response: `{ "chat": ChatSummary }` +- Source chat not found: `404 { "message": "chat not found" }` +- Supplied message missing from the source chat: `404 { "message": "message not found in chat" }` +- Supplied message is not an assistant response: `400 { "message": "fork message must be an assistant response" }` + +Behavior notes: +- With no `messageId`, the server copies the complete source transcript. With an assistant response `messageId`, it copies the transcript through that response, inclusive. +- The source chat is unchanged. Copied messages receive new ids while retaining their role, content, name, creation time, order, and metadata except for the source request's transport-only `clientRequestId`. Attachments and tool-call metadata are retained. LLM call logs, star/project memberships, and other child threads are not copied. +- The fork inherits the source chat's user, initiated/last-used provider and model, additional system prompt, and enabled tool settings. +- A fork of a root or another fork always sets `parentChatId` to the single root chat id. Root chats keep `parentChatId: null`. +- A whole-chat fork starts with `Fork of ` (or `Fork of Untitled chat`). A message fork starts with `Fork of ''`. +- The placeholder title is returned with `titleGenerationPending: true`. After the first new prompt, call `POST /v1/chats/title/suggest`; the placeholder is eligible for replacement exactly once. + ### `PATCH /v1/chats/:chatId` - Body: any subset of `{ "title": string, "additionalSystemPrompt": string|null, "enabledTools": string[] }` - Response: `{ "chat": ChatSummary }` - Blank titles are rejected. The server trims surrounding whitespace before storing the title. +- Setting a title clears `titleGenerationPending`, preventing an in-flight or later automatic suggestion from replacing the manual title. - `additionalSystemPrompt: null` clears the stored prompt. Blank string values are also stored as `null`. - `enabledTools: []` disables Sybil-managed tools for this chat. Omitted settings are left unchanged. - Updating chat fields changes the returned chat's `updatedAt`. @@ -183,14 +203,19 @@ Behavior notes: - Response: `{ "chat": ChatSummary }` Behavior notes: -- If the chat already has a non-empty title, server returns the existing chat unchanged. +- If the chat already has a non-empty title and `titleGenerationPending` is false, server returns the existing chat unchanged. +- A fork placeholder with `titleGenerationPending: true` is eligible for the same title-generation flow as an untitled original chat. A successful or fallback suggestion clears the flag. - If a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title. -- When no title exists at write time, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat. +- For an eligible untitled chat or pending fork placeholder, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat. - If the title provider is unavailable or rejects the request, server still persists a deterministic title derived from the first line of `content` instead of leaving the chat untitled. ### `DELETE /v1/chats/:chatId` - Response: `{ "deleted": true }` - Not found: `404 { "message": "chat not found" }` +- Active chat or fork: `409 { "message": "chat or fork has an active stream" }` +- Concurrent family deletion: `409 { "message": "chat family deletion already in progress" }` +- Deleting a root chat also deletes its grouped forks. Deleting a fork leaves the root and sibling forks unchanged. +- A root cannot be deleted while it or any grouped fork has an active completion stream. A fork cannot be deleted while its own completion stream is active. ### `GET /v1/chats/:chatId` - Response: `{ "chat": ChatDetail }` @@ -429,6 +454,8 @@ Behavior notes: { "id": "...", "title": null, + "parentChatId": null, + "titleGenerationPending": false, "createdAt": "...", "updatedAt": "...", "starred": false, @@ -481,6 +508,8 @@ Behavior notes: { "id": "...", "title": null, + "parentChatId": null, + "titleGenerationPending": false, "createdAt": "...", "updatedAt": "...", "starred": false, diff --git a/docs/api/streaming-chat.md b/docs/api/streaming-chat.md index d5492e3..02950bf 100644 --- a/docs/api/streaming-chat.md +++ b/docs/api/streaming-chat.md @@ -73,8 +73,10 @@ Notes: Persisted chat streams with a `chatId` are backend-owned active runs: - Once started, the backend keeps the stream running even if the HTTP client disconnects or refreshes. +- The backend reserves the active run before persisting submitted messages, preventing a root or fork deletion from interleaving with stream setup. - While running, `GET /v1/active-runs` includes the `chatId`. - Starting a second persisted stream for the same active `chatId` returns `409`, unless its `clientRequestId` matches the active submission, in which case the existing stream is replayed. +- Starting a persisted stream while that chat family is being deleted returns `409 { "message": "chat family deletion already in progress" }`. - Clients can reattach with `POST /v1/chats/:chatId/stream/attach`. ## Attach Endpoint diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift index f38a35c..7bcdc31 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift @@ -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 diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift index 9cc5c91..563f859 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift @@ -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 } } diff --git a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift index ba6d38f..95eae1d 100644 --- a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift +++ b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift @@ -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() diff --git a/server/prisma/migrations/20260816000000_add_chat_forks/migration.sql b/server/prisma/migrations/20260816000000_add_chat_forks/migration.sql new file mode 100644 index 0000000..bf90a76 --- /dev/null +++ b/server/prisma/migrations/20260816000000_add_chat_forks/migration.sql @@ -0,0 +1,5 @@ +-- Add durable root grouping and one-time fork title generation state. +ALTER TABLE "Chat" ADD COLUMN "parentChatId" TEXT REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Chat" ADD COLUMN "titleGenerationPending" BOOLEAN NOT NULL DEFAULT false; + +CREATE INDEX "Chat_parentChatId_idx" ON "Chat"("parentChatId"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 3f08490..7b205c4 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -51,7 +51,8 @@ model Chat { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - title String? + title String? + titleGenerationPending Boolean @default(false) initiatedProvider Provider? initiatedModel String? @@ -64,11 +65,17 @@ model Chat { user User? @relation(fields: [userId], references: [id]) userId String? + // Forks always point directly to the single root chat, never to another fork. + parentChat Chat? @relation("ChatForks", fields: [parentChatId], references: [id], onDelete: Cascade) + parentChatId String? + childChats Chat[] @relation("ChatForks") + messages Message[] calls LlmCall[] projectItems ProjectItem[] @@index([userId]) + @@index([parentChatId]) } model Message { diff --git a/server/src/routes.ts b/server/src/routes.ts index e14613f..9f7379c 100644 --- a/server/src/routes.ts +++ b/server/src/routes.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { performance } from "node:perf_hooks"; import { z } from "zod"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; @@ -18,6 +19,7 @@ import type { ChatAttachment } from "./llm/types.js"; const ProviderSchema = z.enum(["openai", "anthropic", "xai", "gemini", "hermes-agent"]); const MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS = 12_000; +const MAX_FORK_MESSAGE_SNIPPET_CHARS = 48; const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value)); type IncomingChatMessage = { @@ -94,7 +96,7 @@ async function storeNonAssistantMessages(chatId: string, messages: IncomingChatM const existing = await prisma.message.findMany({ where: { chatId }, - orderBy: { createdAt: "asc" }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], select: { role: true, content: true, name: true, metadata: true }, }); const existingNonAssistant = existing.filter((m) => m.role !== "assistant" && !isToolCallLogMessage(m)); @@ -317,6 +319,21 @@ function normalizeSuggestedTitle(raw: string, fallback: string) { return words.slice(0, 4).join(" ").slice(0, 64).trim() || fallback; } +export function buildForkTitle(originalTitle: string | null, messageContent?: string) { + if (messageContent !== undefined) { + const snippet = truncateContextPart(messageContent.replace(/\s+/g, " "), MAX_FORK_MESSAGE_SNIPPET_CHARS) ?? "message"; + return `Fork of '${snippet}'`; + } + return `Fork of ${originalTitle?.trim() || "Untitled chat"}`; +} + +export function copyForkMessageMetadata(metadata: unknown) { + if (metadata === null || metadata === undefined) return undefined; + if (typeof metadata !== "object" || Array.isArray(metadata)) return metadata; + const { clientRequestId: _clientRequestId, ...copied } = metadata as Record; + return Object.keys(copied).length ? copied : undefined; +} + async function generateChatTitle(content: string) { const systemPrompt = "You create short chat titles. Return exactly one line, maximum 4 words, no quotes, no trailing punctuation."; @@ -415,6 +432,7 @@ type SearchRunRequest = z.infer; const activeChatStreams = new Map(); const activeChatStreamRequestIds = new Map(); +const chatDeletionRoots = new Set(); const activeSearchStreams = new Map(); const STARRED_PROJECT_ID = "starred"; @@ -427,6 +445,8 @@ const starredProjectItemsSelect = { const chatSummarySelect = { id: true, title: true, + titleGenerationPending: true, + parentChatId: true, createdAt: true, updatedAt: true, initiatedProvider: true, @@ -507,6 +527,29 @@ async function getSearchSummary(searchId: string) { return search ? serializeSearchLike(search) : null; } +async function listRecentChatsWithRoots() { + const chats = await prisma.chat.findMany({ + orderBy: { updatedAt: "desc" }, + take: 100, + select: chatSummarySelect, + }); + const includedIds = new Set(chats.map((chat) => chat.id)); + const missingRootIds = [ + ...new Set( + chats + .map((chat) => chat.parentChatId) + .filter((id): id is string => id !== null && !includedIds.has(id)) + ), + ]; + if (!missingRootIds.length) return chats; + + const roots = await prisma.chat.findMany({ + where: { id: { in: missingRootIds } }, + select: chatSummarySelect, + }); + return [...chats, ...roots].sort(compareUpdatedAtDesc); +} + async function setChatStarred(chatId: string, starred: boolean) { const exists = await prisma.chat.findUnique({ where: { id: chatId }, select: { id: true } }); if (!exists) return null; @@ -545,11 +588,7 @@ async function setSearchStarred(searchId: string, starred: boolean) { async function listWorkspaceItems() { const [chats, searches] = await Promise.all([ - prisma.chat.findMany({ - orderBy: { updatedAt: "desc" }, - take: 100, - select: chatSummarySelect, - }), + listRecentChatsWithRoots(), prisma.search.findMany({ orderBy: { updatedAt: "desc" }, take: 100, @@ -605,7 +644,7 @@ function mapChatStreamEvent(ev: StreamEvent): SseStreamEvent { return { event: ev.type, data: ev }; } -function registerActiveChatStream(chatId: string, clientRequestId?: string) { +export function registerActiveChatStream(chatId: string, clientRequestId?: string) { const stream = new ActiveSseStream(); activeChatStreams.set(chatId, stream); if (clientRequestId) { @@ -616,7 +655,7 @@ function registerActiveChatStream(chatId: string, clientRequestId?: string) { return stream; } -function clearActiveChatStream(chatId: string, stream: ActiveSseStream) { +export function clearActiveChatStream(chatId: string, stream: ActiveSseStream) { if (activeChatStreams.get(chatId) !== stream) return; activeChatStreams.delete(chatId); activeChatStreamRequestIds.delete(chatId); @@ -647,12 +686,6 @@ function executeActiveChatStream(chatId: string, body: z.infer) { - const stream = registerActiveChatStream(chatId, body.clientRequestId); - executeActiveChatStream(chatId, body, stream); - return stream; -} - function getMetadataClientRequestId(metadata: unknown) { if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null; const clientRequestId = (metadata as Record).clientRequestId; @@ -888,11 +921,7 @@ export async function registerRoutes(app: FastifyInstance) { app.get("/v1/chats", async (req) => { requireAdmin(req); - const chats = await prisma.chat.findMany({ - orderBy: { updatedAt: "desc" }, - take: 100, - select: chatSummarySelect, - }); + const chats = await listRecentChatsWithRoots(); return { chats: chats.map((chat) => serializeChatLike(chat)) }; }); @@ -951,6 +980,80 @@ export async function registerRoutes(app: FastifyInstance) { return { chat: serializeChatLike(chat) }; }); + app.post("/v1/chats/:chatId/fork", async (req) => { + requireAdmin(req); + const Params = z.object({ chatId: z.string() }); + const Body = z.object({ messageId: z.string().trim().min(1).optional() }); + const { chatId } = Params.parse(req.params); + const parsed = Body.safeParse(req.body ?? {}); + if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message); + + const result = await prisma.$transaction(async (tx) => { + const source = await tx.chat.findUnique({ + where: { id: chatId }, + select: { + id: true, + title: true, + parentChatId: true, + initiatedProvider: true, + initiatedModel: true, + lastUsedProvider: true, + lastUsedModel: true, + additionalSystemPrompt: true, + enabledTools: true, + userId: true, + messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] }, + }, + }); + if (!source) return { status: "chat-not-found" as const }; + + let messages = source.messages; + let selectedMessage: (typeof source.messages)[number] | undefined; + if (parsed.data.messageId) { + const selectedIndex = messages.findIndex((message) => message.id === parsed.data.messageId); + if (selectedIndex < 0) return { status: "message-not-found" as const }; + selectedMessage = messages[selectedIndex]; + if (selectedMessage.role !== "assistant") return { status: "message-not-assistant" as const }; + messages = messages.slice(0, selectedIndex + 1); + } + + const forkMessageIdPrefix = `fork-${randomUUID()}`; + const chat = await tx.chat.create({ + data: { + title: buildForkTitle(source.title, selectedMessage?.content), + titleGenerationPending: true, + parentChatId: source.parentChatId ?? source.id, + initiatedProvider: source.initiatedProvider, + initiatedModel: source.initiatedModel, + lastUsedProvider: source.lastUsedProvider, + lastUsedModel: source.lastUsedModel, + additionalSystemPrompt: source.additionalSystemPrompt, + enabledTools: (source.enabledTools ?? undefined) as any, + userId: source.userId, + messages: messages.length + ? { + create: messages.map((message, index) => ({ + id: `${forkMessageIdPrefix}-${String(index).padStart(8, "0")}`, + createdAt: message.createdAt, + role: message.role, + content: message.content, + name: message.name, + metadata: copyForkMessageMetadata(message.metadata) as any, + })), + } + : undefined, + }, + select: chatSummarySelect, + }); + return { status: "created" as const, chat }; + }); + + if (result.status === "chat-not-found") return app.httpErrors.notFound("chat not found"); + if (result.status === "message-not-found") return app.httpErrors.notFound("message not found in chat"); + if (result.status === "message-not-assistant") return app.httpErrors.badRequest("fork message must be an assistant response"); + return { chat: serializeChatLike(result.chat) }; + }); + app.patch("/v1/chats/:chatId", async (req) => { requireAdmin(req); const Params = z.object({ chatId: z.string() }); @@ -963,7 +1066,10 @@ export async function registerRoutes(app: FastifyInstance) { const body = Body.parse(req.body ?? {}); const data: Record = {}; - if (body.title !== undefined) data.title = body.title; + if (body.title !== undefined) { + data.title = body.title; + data.titleGenerationPending = false; + } if (body.additionalSystemPrompt !== undefined) data.additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt); if (body.enabledTools !== undefined) data.enabledTools = body.enabledTools; @@ -1004,7 +1110,7 @@ export async function registerRoutes(app: FastifyInstance) { select: chatSummarySelect, }); if (!existing) return app.httpErrors.notFound("chat not found"); - if (existing.title?.trim()) return { chat: serializeChatLike(existing) }; + if (existing.title?.trim() && !existing.titleGenerationPending) return { chat: serializeChatLike(existing) }; const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat"; let suggestedRaw = ""; @@ -1022,8 +1128,12 @@ export async function registerRoutes(app: FastifyInstance) { const title = normalizeSuggestedTitle(suggestedRaw, fallback); await prisma.chat.updateMany({ - where: { id: body.chatId, title: existing.title }, - data: { title }, + where: { + id: body.chatId, + title: existing.title, + titleGenerationPending: existing.titleGenerationPending, + }, + data: { title, titleGenerationPending: false }, }); const chat = await getChatSummary(body.chatId); @@ -1039,14 +1149,46 @@ export async function registerRoutes(app: FastifyInstance) { req.log.info({ chatId }, "delete chat requested"); - const result = await prisma.chat.deleteMany({ where: { id: chatId } }); - if (result.count === 0) { + const target = await prisma.chat.findUnique({ + where: { id: chatId }, + select: { parentChatId: true }, + }); + if (!target) { req.log.warn({ chatId }, "delete chat target not found"); return app.httpErrors.notFound("chat not found"); } - req.log.info({ chatId }, "chat deleted"); - return { deleted: true }; + const familyRootId = target.parentChatId ?? chatId; + if (chatDeletionRoots.has(familyRootId)) { + return app.httpErrors.conflict("chat family deletion already in progress"); + } + + chatDeletionRoots.add(familyRootId); + try { + const familyIds = target.parentChatId + ? [chatId] + : ( + await prisma.chat.findMany({ + where: { OR: [{ id: chatId }, { parentChatId: chatId }] }, + select: { id: true }, + }) + ).map((chat) => chat.id); + if (familyIds.some((id) => activeChatStreams.has(id))) { + req.log.warn({ chatId }, "delete chat rejected while chat family is active"); + return app.httpErrors.conflict("chat or fork has an active stream"); + } + + const result = await prisma.chat.deleteMany({ where: { id: chatId } }); + if (result.count === 0) { + req.log.warn({ chatId }, "delete chat target no longer exists"); + return app.httpErrors.notFound("chat not found"); + } + + req.log.info({ chatId }, "chat deleted"); + return { deleted: true }; + } finally { + chatDeletionRoots.delete(familyRootId); + } }); app.get("/v1/searches", async (req) => { @@ -1336,7 +1478,7 @@ export async function registerRoutes(app: FastifyInstance) { const chat = await prisma.chat.findUnique({ where: { id: chatId }, include: { - messages: { orderBy: { createdAt: "asc" } }, + messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] }, calls: { orderBy: { createdAt: "desc" } }, projectItems: starredProjectItemsSelect, }, @@ -1430,10 +1572,15 @@ export async function registerRoutes(app: FastifyInstance) { if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message); const body = withRequestUserLocation(parsed.data, req); - // ensure chat exists if provided + // Ensure the chat exists and identify its family before reserving a stream. + let chatFamilyRootId: string | null = null; if (body.chatId) { - const exists = await prisma.chat.findUnique({ where: { id: body.chatId }, select: { id: true } }); + const exists = await prisma.chat.findUnique({ + where: { id: body.chatId }, + select: { id: true, parentChatId: true }, + }); if (!exists) return app.httpErrors.notFound("chat not found"); + chatFamilyRootId = exists.parentChatId ?? exists.id; } if (body.persist !== false && body.chatId) { @@ -1445,32 +1592,31 @@ export async function registerRoutes(app: FastifyInstance) { return app.httpErrors.conflict("chat completion already running"); } - if (body.clientRequestId) { - const reservedStream = registerActiveChatStream(body.chatId, body.clientRequestId); - try { + if (chatFamilyRootId && chatDeletionRoots.has(chatFamilyRootId)) { + return app.httpErrors.conflict("chat family deletion already in progress"); + } + + const reservedStream = registerActiveChatStream(body.chatId, body.clientRequestId); + try { + if (body.clientRequestId) { const completedSubmission = await findCompletedChatSubmission(body.chatId, body.clientRequestId); if (completedSubmission) { completeChatSubmissionStream(reservedStream, body.chatId, body, completedSubmission.content); clearActiveChatStream(body.chatId, reservedStream); return streamActiveRun(req, reply, reservedStream); } - - // Store only new non-assistant messages to avoid duplicate history entries. - await storeNonAssistantMessages(body.chatId, body.messages, body.clientRequestId); - const configuredBody = await applyStoredChatSettings(body); - executeActiveChatStream(body.chatId, configuredBody, reservedStream); - return streamActiveRun(req, reply, reservedStream); - } catch (err) { - reservedStream.complete({ event: "error", data: { message: getErrorMessage(err) } }); - clearActiveChatStream(body.chatId, reservedStream); - throw err; } - } - // Legacy requests without an idempotency key retain the original behavior. - await storeNonAssistantMessages(body.chatId, body.messages); - const stream = startActiveChatStream(body.chatId, await applyStoredChatSettings(body)); - return streamActiveRun(req, reply, stream); + // Reserve the stream before persistence so deletion cannot interleave with setup. + await storeNonAssistantMessages(body.chatId, body.messages, body.clientRequestId); + const configuredBody = await applyStoredChatSettings(body); + executeActiveChatStream(body.chatId, configuredBody, reservedStream); + return streamActiveRun(req, reply, reservedStream); + } catch (err) { + reservedStream.complete({ event: "error", data: { message: getErrorMessage(err) } }); + clearActiveChatStream(body.chatId, reservedStream); + throw err; + } } reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined)); diff --git a/server/tests/chat-forks.test.ts b/server/tests/chat-forks.test.ts new file mode 100644 index 0000000..25b9c3b --- /dev/null +++ b/server/tests/chat-forks.test.ts @@ -0,0 +1,252 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import Fastify from "fastify"; +import sensible from "@fastify/sensible"; + +const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const databaseDir = mkdtempSync(join(tmpdir(), "sybil-chat-forks-")); +process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`; +process.env.OPENAI_API_KEY = ""; +delete process.env.ADMIN_TOKEN; + +execFileSync(process.execPath, [join(serverRoot, "node_modules/prisma/build/index.js"), "migrate", "deploy"], { + cwd: serverRoot, + env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: "1" }, + stdio: "pipe", +}); + +const [{ clearActiveChatStream, registerActiveChatStream, registerRoutes }, { prisma }] = await Promise.all([ + import("../src/routes.js"), + import("../src/db.js"), +]); +const app = Fastify({ logger: false }); +await app.register(sensible); +await registerRoutes(app); +await app.ready(); + +test.after(async () => { + await app.close(); + await prisma.$disconnect(); + rmSync(databaseDir, { recursive: true, force: true }); +}); + +test("forks copy bounded history and keep every child grouped under the root", async () => { + const timestamp = new Date("2026-08-16T12:00:00.000Z"); + const source = await prisma.chat.create({ + data: { + title: "Planning session", + initiatedProvider: "openai", + initiatedModel: "gpt-4.1-mini", + lastUsedProvider: "anthropic", + lastUsedModel: "claude-sonnet-4-20250514", + additionalSystemPrompt: "Keep answers concise.", + enabledTools: ["web_search"], + messages: { + create: [ + { + id: "source-message-0001", + createdAt: timestamp, + role: "user", + content: "Plan a trip", + metadata: { + clientRequestId: "source-request", + attachments: [{ kind: "text", filename: "notes.txt" }], + }, + }, + { + id: "source-message-0002", + createdAt: timestamp, + role: "assistant", + content: "First assistant response", + metadata: { clientRequestId: "source-request", citations: ["https://example.com"] }, + }, + { + id: "source-message-0003", + createdAt: timestamp, + role: "user", + content: "Only visible in a whole-chat fork", + }, + ], + }, + calls: { + create: { + provider: "openai", + model: "gpt-4.1-mini", + request: { input: "Plan a trip" }, + }, + }, + }, + include: { messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] } }, + }); + + const forkResponse = await app.inject({ + method: "POST", + url: `/v1/chats/${source.id}/fork`, + payload: { messageId: source.messages[1].id }, + }); + assert.equal(forkResponse.statusCode, 200, forkResponse.body); + const forkSummary = forkResponse.json().chat; + assert.equal(forkSummary.parentChatId, source.id); + assert.equal(forkSummary.title, "Fork of 'First assistant response'"); + assert.equal(forkSummary.titleGenerationPending, true); + assert.equal(forkSummary.starred, false); + + const detailResponse = await app.inject({ method: "GET", url: `/v1/chats/${forkSummary.id}` }); + assert.equal(detailResponse.statusCode, 200, detailResponse.body); + const fork = detailResponse.json().chat; + assert.deepEqual( + fork.messages.map((message: any) => [message.role, message.content]), + [ + ["user", "Plan a trip"], + ["assistant", "First assistant response"], + ] + ); + assert.notEqual(fork.messages[0].id, source.messages[0].id); + assert.equal(fork.messages[0].createdAt, timestamp.toISOString()); + assert.deepEqual(fork.messages[0].metadata, { + attachments: [{ kind: "text", filename: "notes.txt" }], + }); + assert.deepEqual(fork.messages[1].metadata, { citations: ["https://example.com"] }); + assert.equal(fork.initiatedProvider, "openai"); + assert.equal(fork.initiatedModel, "gpt-4.1-mini"); + assert.equal(fork.lastUsedProvider, "anthropic"); + assert.equal(fork.lastUsedModel, "claude-sonnet-4-20250514"); + assert.equal(fork.additionalSystemPrompt, "Keep answers concise."); + assert.deepEqual(fork.enabledTools, ["web_search"]); + assert.deepEqual(fork.calls, []); + + const childForkResponse = await app.inject({ + method: "POST", + url: `/v1/chats/${forkSummary.id}/fork`, + payload: {}, + }); + assert.equal(childForkResponse.statusCode, 200, childForkResponse.body); + const childFork = childForkResponse.json().chat; + assert.equal(childFork.parentChatId, source.id); + assert.equal(childFork.title, "Fork of Fork of 'First assistant response'"); + assert.equal(childFork.titleGenerationPending, true); + + const wholeForkResponse = await app.inject({ + method: "POST", + url: `/v1/chats/${source.id}/fork`, + payload: {}, + }); + assert.equal(wholeForkResponse.statusCode, 200, wholeForkResponse.body); + const wholeFork = wholeForkResponse.json().chat; + assert.equal(wholeFork.parentChatId, source.id); + assert.equal(wholeFork.title, "Fork of Planning session"); + const wholeForkDetail = await app.inject({ method: "GET", url: `/v1/chats/${wholeFork.id}` }); + assert.equal(wholeForkDetail.statusCode, 200, wholeForkDetail.body); + assert.deepEqual( + wholeForkDetail.json().chat.messages.map((message: any) => message.content), + ["Plan a trip", "First assistant response", "Only visible in a whole-chat fork"] + ); + assert.equal(wholeForkDetail.json().chat.messages[2].metadata, null); + + const suggestedTitleResponse = await app.inject({ + method: "POST", + url: "/v1/chats/title/suggest", + payload: { chatId: wholeFork.id, content: "Compare rail and air options" }, + }); + assert.equal(suggestedTitleResponse.statusCode, 200, suggestedTitleResponse.body); + assert.equal(suggestedTitleResponse.json().chat.title, "Compare rail and air"); + assert.equal(suggestedTitleResponse.json().chat.titleGenerationPending, false); + + const repeatedTitleResponse = await app.inject({ + method: "POST", + url: "/v1/chats/title/suggest", + payload: { chatId: wholeFork.id, content: "This must not overwrite the generated title" }, + }); + assert.equal(repeatedTitleResponse.statusCode, 200, repeatedTitleResponse.body); + assert.equal(repeatedTitleResponse.json().chat.title, "Compare rail and air"); + + const sourceAfterForks = await prisma.chat.findUniqueOrThrow({ + where: { id: source.id }, + include: { messages: true, calls: true }, + }); + assert.equal(sourceAfterForks.messages.length, 3); + assert.equal(sourceAfterForks.calls.length, 1); + + const manualTitleResponse = await app.inject({ + method: "PATCH", + url: `/v1/chats/${forkSummary.id}`, + payload: { title: "Manual branch title" }, + }); + assert.equal(manualTitleResponse.statusCode, 200, manualTitleResponse.body); + assert.equal(manualTitleResponse.json().chat.titleGenerationPending, false); + + const activeForkStream = registerActiveChatStream(wholeFork.id); + try { + const deleteActiveRootResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${source.id}` }); + assert.equal(deleteActiveRootResponse.statusCode, 409, deleteActiveRootResponse.body); + assert.equal(deleteActiveRootResponse.json().message, "chat or fork has an active stream"); + + const deleteActiveForkResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${wholeFork.id}` }); + assert.equal(deleteActiveForkResponse.statusCode, 409, deleteActiveForkResponse.body); + } finally { + clearActiveChatStream(wholeFork.id, activeForkStream); + } + + const deleteChildResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${childFork.id}` }); + assert.equal(deleteChildResponse.statusCode, 200, deleteChildResponse.body); + assert.equal(await prisma.chat.count({ where: { id: { in: [source.id, forkSummary.id, wholeFork.id] } } }), 3); + + const unrelated = await prisma.chat.create({ + data: { + messages: { create: { role: "assistant", content: "Unrelated response" } }, + }, + include: { messages: true }, + }); + const countBeforeInvalidForks = await prisma.chat.count(); + + const wrongChatResponse = await app.inject({ + method: "POST", + url: `/v1/chats/${source.id}/fork`, + payload: { messageId: unrelated.messages[0].id }, + }); + assert.equal(wrongChatResponse.statusCode, 404, wrongChatResponse.body); + assert.equal(wrongChatResponse.json().message, "message not found in chat"); + + const nonAssistantResponse = await app.inject({ + method: "POST", + url: `/v1/chats/${source.id}/fork`, + payload: { messageId: source.messages[0].id }, + }); + assert.equal(nonAssistantResponse.statusCode, 400, nonAssistantResponse.body); + assert.equal(nonAssistantResponse.json().message, "fork message must be an assistant response"); + assert.equal(await prisma.chat.count(), countBeforeInvalidForks); + + const concurrentTarget = await prisma.chat.create({ data: { title: "Concurrent delete" } }); + const concurrentDeleteResponses = await Promise.all([ + app.inject({ method: "DELETE", url: `/v1/chats/${concurrentTarget.id}` }), + app.inject({ method: "DELETE", url: `/v1/chats/${concurrentTarget.id}` }), + ]); + assert.equal(concurrentDeleteResponses.filter((response) => response.statusCode === 200).length, 1); + assert.equal(concurrentDeleteResponses.some((response) => response.statusCode >= 500), false); + + const concurrentRoot = await prisma.chat.create({ data: { title: "Concurrent family delete" } }); + const concurrentChildResponse = await app.inject({ + method: "POST", + url: `/v1/chats/${concurrentRoot.id}/fork`, + payload: {}, + }); + assert.equal(concurrentChildResponse.statusCode, 200, concurrentChildResponse.body); + const concurrentChild = concurrentChildResponse.json().chat; + const concurrentFamilyDeleteResponses = await Promise.all([ + app.inject({ method: "DELETE", url: `/v1/chats/${concurrentRoot.id}` }), + app.inject({ method: "DELETE", url: `/v1/chats/${concurrentChild.id}` }), + ]); + assert.equal(concurrentFamilyDeleteResponses.some((response) => response.statusCode >= 500), false); + assert.equal(concurrentFamilyDeleteResponses.some((response) => response.statusCode === 200), true); + await prisma.chat.deleteMany({ where: { id: concurrentRoot.id } }); + + const deleteRootResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${source.id}` }); + assert.equal(deleteRootResponse.statusCode, 200, deleteRootResponse.body); + assert.equal(await prisma.chat.count({ where: { id: { in: [source.id, forkSummary.id, childFork.id, wholeFork.id] } } }), 0); + assert.equal(await prisma.chat.count({ where: { id: unrelated.id } }), 1); +}); diff --git a/tui/src/index.ts b/tui/src/index.ts index 0fc80e5..9f45637 100644 --- a/tui/src/index.ts +++ b/tui/src/index.ts @@ -127,21 +127,27 @@ function upsertWorkspaceItem(items: WorkspaceItem[], item: WorkspaceItem) { } function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] { + const chatsById = new Map(); + for (const item of items) { + if (item.type === "chat") chatsById.set(item.id, item); + } + return items.map((item) => { if (item.type === "chat") { const chat = item; + const starOwner = chatsById.get(chat.parentChatId ?? chat.id) ?? chat; return { - kind: "chat" as const, - id: chat.id, - title: getChatTitle(chat), - updatedAt: chat.updatedAt, - createdAt: chat.createdAt, - starred: chat.starred, - starredAt: chat.starredAt, - initiatedProvider: chat.initiatedProvider, - initiatedModel: chat.initiatedModel, - lastUsedProvider: chat.lastUsedProvider, - lastUsedModel: chat.lastUsedModel, + kind: "chat" as const, + id: chat.id, + title: getChatTitle(chat), + updatedAt: chat.updatedAt, + createdAt: chat.createdAt, + starred: starOwner.starred, + starredAt: starOwner.starredAt, + initiatedProvider: chat.initiatedProvider, + initiatedModel: chat.initiatedModel, + lastUsedProvider: chat.lastUsedProvider, + lastUsedModel: chat.lastUsedModel, }; } @@ -979,11 +985,11 @@ async function main() { focusComposer(); } - async function maybeSuggestTitle(chatId: string, content: string) { - const chatSummary = chats.find((chat) => chat.id === chatId); - const hasExistingTitle = Boolean(selectedChat?.id === chatId ? selectedChat.title?.trim() : chatSummary?.title?.trim()); - if (hasExistingTitle || pendingTitleGeneration.has(chatId)) return; + async function maybeSuggestTitle(chat: ChatDetail, content: string) { + const needsGeneratedTitle = chat.titleGenerationPending || !chat.title?.trim(); + if (!needsGeneratedTitle || pendingTitleGeneration.has(chat.id)) return; + const chatId = chat.id; pendingTitleGeneration.add(chatId); try { const updated = await api.suggestChatTitle({ chatId, content }); @@ -993,6 +999,8 @@ async function main() { selectedChat = { ...selectedChat, title: updated.title, + parentChatId: updated.parentChatId, + titleGenerationPending: updated.titleGenerationPending, updatedAt: updated.updatedAt, starred: updated.starred, starredAt: updated.starredAt, @@ -1049,6 +1057,8 @@ async function main() { selectedChat = { id: chat.id, title: chat.title, + parentChatId: chat.parentChatId, + titleGenerationPending: chat.titleGenerationPending, createdAt: chat.createdAt, updatedAt: chat.updatedAt, starred: chat.starred, @@ -1068,13 +1078,13 @@ async function main() { throw new Error("Unable to initialize chat"); } - void maybeSuggestTitle(chatId, content); - let baseChat = selectedChat; if (!baseChat || baseChat.id !== chatId) { baseChat = await api.getChat(chatId); } + void maybeSuggestTitle(baseChat, content); + const requestMessages: CompletionRequestMessage[] = [ ...baseChat.messages .filter((message) => !isToolCallLogMessage(message)) @@ -1392,6 +1402,8 @@ async function main() { selectedChat = { ...selectedChat, title: updated.title, + parentChatId: updated.parentChatId, + titleGenerationPending: updated.titleGenerationPending, updatedAt: updated.updatedAt, initiatedProvider: updated.initiatedProvider, initiatedModel: updated.initiatedModel, @@ -1405,12 +1417,16 @@ async function main() { async function handleToggleStarSelection() { if (!selectedItem) return; - const currentItem = getSidebarItems().find((item) => item.kind === selectedItem?.kind && item.id === selectedItem?.id); - const nextStarred = !currentItem?.starred; setError(null); if (selectedItem.kind === "chat") { - const updated = await api.updateChatStar(selectedItem.id, nextStarred); + const selectedSummary = chats.find((chat) => chat.id === selectedItem?.id); + const selectedParentChatId = selectedChat?.id === selectedItem.id + ? selectedChat.parentChatId + : selectedSummary?.parentChatId; + const rootChatId = selectedParentChatId ?? selectedItem.id; + const rootSummary = chats.find((chat) => chat.id === rootChatId); + const updated = await api.updateChatStar(rootChatId, !rootSummary?.starred); chats = chats.map((chat) => (chat.id === updated.id ? updated : chat)); if (!chats.some((chat) => chat.id === updated.id)) chats = [updated, ...chats]; workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item)); @@ -1421,6 +1437,8 @@ async function main() { selectedChat = { ...selectedChat, title: updated.title, + parentChatId: updated.parentChatId, + titleGenerationPending: updated.titleGenerationPending, updatedAt: updated.updatedAt, starred: updated.starred, starredAt: updated.starredAt, @@ -1431,7 +1449,8 @@ async function main() { }; } } else { - const updated = await api.updateSearchStar(selectedItem.id, nextStarred); + const currentItem = getSidebarItems().find((item) => item.kind === "search" && item.id === selectedItem?.id); + const updated = await api.updateSearchStar(selectedItem.id, !currentItem?.starred); searches = searches.map((search) => (search.id === updated.id ? updated : search)); if (!searches.some((search) => search.id === updated.id)) searches = [updated, ...searches]; workspaceItems = workspaceItems.map((item) => (item.type === "search" && item.id === updated.id ? searchWorkspaceItem(updated) : item)); diff --git a/tui/src/types.ts b/tui/src/types.ts index d6718d2..b979919 100644 --- a/tui/src/types.ts +++ b/tui/src/types.ts @@ -13,6 +13,8 @@ export type ModelCatalogResponse = { export type ChatSummary = { id: string; title: string | null; + parentChatId: string | null; + titleGenerationPending: boolean; createdAt: string; updatedAt: string; starred: boolean; @@ -68,6 +70,8 @@ export type ToolCallEvent = { export type ChatDetail = { id: string; title: string | null; + parentChatId: string | null; + titleGenerationPending: boolean; createdAt: string; updatedAt: string; starred: boolean; diff --git a/web/src/App.tsx b/web/src/App.tsx index f86221e..17a55e4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,6 +3,7 @@ import type { TargetedTouchEvent } from "preact"; import { Check, ChevronDown, + GitFork, Globe2, LoaderCircle, Menu, @@ -32,6 +33,7 @@ import { createSearch, deleteChat, deleteSearch, + forkChat, attachCompletionStream, attachSearchStream, getActiveRuns, @@ -67,6 +69,12 @@ import { getChatModelSelection, getChatModelSelectionSyncKey, } from "@/lib/chat-model-selection"; +import { + filterSidebarItemsWithForkGroups, + getForkGroupPresentation, + groupForkedChatItems, + shouldRequestChatTitle, +} from "@/lib/chat-forking"; import { resolveSidebarSelectionAfterRefresh, type SidebarSelection, @@ -76,6 +84,7 @@ import { cn } from "@/lib/utils"; type DraftSelectionKind = "chat" | "search"; type SidebarItem = SidebarSelection & { title: string; + parentChatId: string | null; updatedAt: string; createdAt: string; starred: boolean; @@ -86,9 +95,16 @@ type SidebarItem = SidebarSelection & { lastUsedModel: string | null; }; type ContextMenuState = { + kind: "sidebar"; item: SidebarSelection; x: number; y: number; +} | { + kind: "message"; + chatId: string; + messageId: string; + x: number; + y: number; }; type RenameChatDialogState = { chatId: string; @@ -809,6 +825,7 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] { kind: "chat" as const, id: chat.id, title: getChatTitle(chat), + parentChatId: chat.parentChatId, updatedAt: chat.updatedAt, createdAt: chat.createdAt, starred: chat.starred, @@ -825,6 +842,7 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] { kind: "search" as const, id: search.id, title: getSearchTitle(search), + parentChatId: null, updatedAt: search.updatedAt, createdAt: search.createdAt, starred: search.starred, @@ -879,20 +897,33 @@ function getSidebarSectionLabel(value: string) { return "EARLIER"; } -function buildSidebarSections(items: SidebarItem[]) { - const starred = items - .filter((item) => item.starred) +function buildSidebarSections(items: SidebarItem[], allItems = items) { + const groups = groupForkedChatItems(items).map((groupItems) => { + const presentation = getForkGroupPresentation(groupItems, allItems); + return { + items: groupItems, + updatedAt: presentation?.updatedAt ?? groupItems[0].updatedAt, + starred: presentation?.starred ?? false, + starredAt: presentation?.starredAt ?? null, + }; + }); + const starred = groups + .filter((group) => group.starred) .sort((a, b) => new Date(b.starredAt ?? b.updatedAt).getTime() - new Date(a.starredAt ?? a.updatedAt).getTime()); - const unstarred = items.filter((item) => !item.starred); + const unstarred = groups + .filter((group) => !group.starred) + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); - const sections = starred.length ? [{ label: "STARRED", items: starred }] : []; - return unstarred.reduce>((sections, item) => { - const label = getSidebarSectionLabel(item.updatedAt); + const sections: Array<{ label: string; items: SidebarItem[] }> = starred.length + ? [{ label: "STARRED", items: starred.flatMap((group) => group.items) }] + : []; + return unstarred.reduce>((sections, group) => { + const label = getSidebarSectionLabel(group.updatedAt); const section = sections.find((candidate) => candidate.label === label); if (section) { - section.items.push(item); + section.items.push(...group.items); } else { - sections.push({ label, items: [item] }); + sections.push({ label, items: [...group.items] }); } return sections; }, sections); @@ -922,6 +953,7 @@ export default function App() { const [isLoadingCollections, setIsLoadingCollections] = useState(false); const [isLoadingSelection, setIsLoadingSelection] = useState(false); const [isStartingSearchChat, setIsStartingSearchChat] = useState(false); + const [isForkingChat, setIsForkingChat] = useState(false); const [pendingChatStates, setPendingChatStates] = useState>({}); const [runningSearchStates, setRunningSearchStates] = useState>({}); const [activeRuns, setActiveRuns] = useState(EMPTY_ACTIVE_RUNS); @@ -979,6 +1011,12 @@ export default function App() { const pendingAttachmentsRef = useRef([]); const composerDraftRef = useRef(""); const selectedItemRef = useRef(null); + const draftKindRef = useRef(null); + const collectionLoadRequestRef = useRef(0); + const activeCollectionLoadRef = useRef<{ requestId: number; options: RefreshCollectionsOptions } | null>(null); + const selectionLoadRequestRef = useRef(0); + const forkRequestSequenceRef = useRef(0); + const activeForkRequestRef = useRef(null); const pendingTitleGenerationRef = useRef>(new Set()); const chatStreamAbortRefs = useRef>(new Map()); const backgroundSuspendedChatStreamsRef = useRef>(new Set()); @@ -1063,17 +1101,32 @@ export default function App() { const sidebarItems = useMemo(() => buildSidebarItems(workspaceItems), [workspaceItems]); const filteredSidebarItems = useMemo(() => { const query = sidebarQuery.trim().toLowerCase(); - if (!query) return sidebarItems; - return sidebarItems.filter((item) => { + if (!query) return groupForkedChatItems(sidebarItems).flat(); + return filterSidebarItemsWithForkGroups(sidebarItems, (item) => { const providerLabel = getProviderLabel(item.lastUsedProvider || item.initiatedProvider).toLowerCase(); return [item.title, item.initiatedModel, item.lastUsedModel, providerLabel] .filter(Boolean) .some((value) => String(value).toLowerCase().includes(query)); }); }, [sidebarItems, sidebarQuery]); - const sidebarSections = useMemo(() => buildSidebarSections(filteredSidebarItems), [filteredSidebarItems]); + const sidebarSections = useMemo( + () => buildSidebarSections(filteredSidebarItems, sidebarItems), + [filteredSidebarItems, sidebarItems] + ); + const orderedSidebarItems = useMemo(() => sidebarSections.flatMap((section) => section.items), [sidebarSections]); + + const cancelCollectionLoad = () => { + collectionLoadRequestRef.current += 1; + activeCollectionLoadRef.current = null; + setIsLoadingCollections(false); + }; const resetWorkspaceState = () => { + cancelCollectionLoad(); + selectionLoadRequestRef.current += 1; + forkRequestSequenceRef.current += 1; + activeForkRequestRef.current = null; + draftKindRef.current = null; setChats([]); setSearches([]); setWorkspaceItems([]); @@ -1119,6 +1172,7 @@ export default function App() { setRenameChatDraft(""); setRenameChatError(null); setIsRenamingChat(false); + setIsForkingChat(false); setError(null); }; @@ -1132,32 +1186,52 @@ export default function App() { reportTransientError = true, selectFallback = false, }: RefreshCollectionsOptions = {}) => { + const requestId = ++collectionLoadRequestRef.current; + activeCollectionLoadRef.current = { + requestId, + options: { initialSelection, reportTransientError, selectFallback }, + }; setIsLoadingCollections(true); try { const nextWorkspaceItems = await listWorkspaceItems(); + if (requestId !== collectionLoadRequestRef.current) return; const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems); setWorkspaceItems(nextWorkspaceItems); setChats(nextChats); setSearches(nextSearches); - setSelectedItem((current) => - resolveSidebarSelectionAfterRefresh(current, nextWorkspaceItems, { + setSelectedItem((current) => { + const nextSelection = resolveSidebarSelectionAfterRefresh(current, nextWorkspaceItems, { initialSelection, selectFallback, - }) - ); + }); + selectedItemRef.current = nextSelection; + return nextSelection; + }); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { handleAuthFailure(message); - } else if (reportTransientError || !isRecoverableStreamDisconnect(err)) { + } else if ( + requestId === collectionLoadRequestRef.current && + (reportTransientError || !isRecoverableStreamDisconnect(err)) + ) { setError(message); } } finally { - setIsLoadingCollections(false); + if (requestId === collectionLoadRequestRef.current) { + activeCollectionLoadRef.current = null; + setIsLoadingCollections(false); + } } }; + const supersedeCollectionLoad = () => { + const replacementOptions = activeCollectionLoadRef.current?.options; + cancelCollectionLoad(); + if (replacementOptions) void refreshCollections(replacementOptions); + }; + const refreshModels = async () => { try { const data = await listModels(); @@ -1200,38 +1274,44 @@ export default function App() { }; const refreshChat = async (chatId: string) => { + const requestId = ++selectionLoadRequestRef.current; setIsLoadingSelection(true); try { const chat = await getChat(chatId); + const current = selectedItemRef.current; + if (requestId !== selectionLoadRequestRef.current || current?.kind !== "chat" || current.id !== chatId) return; setSelectedChat(chat); setSelectedSearch(null); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { handleAuthFailure(message); - } else if (!isRecoverableStreamDisconnect(err)) { + } else if (requestId === selectionLoadRequestRef.current && !isRecoverableStreamDisconnect(err)) { setError(message); } } finally { - setIsLoadingSelection(false); + if (requestId === selectionLoadRequestRef.current) setIsLoadingSelection(false); } }; const refreshSearch = async (searchId: string) => { + const requestId = ++selectionLoadRequestRef.current; setIsLoadingSelection(true); try { const search = await getSearch(searchId); + const current = selectedItemRef.current; + if (requestId !== selectionLoadRequestRef.current || current?.kind !== "search" || current.id !== searchId) return; setSelectedSearch(search); setSelectedChat(null); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { handleAuthFailure(message); - } else if (!isRecoverableStreamDisconnect(err)) { + } else if (requestId === selectionLoadRequestRef.current && !isRecoverableStreamDisconnect(err)) { setError(message); } } finally { - setIsLoadingSelection(false); + if (requestId === selectionLoadRequestRef.current) setIsLoadingSelection(false); } }; @@ -1296,8 +1376,11 @@ export default function App() { useEffect(() => { const onPopState = () => { setContextMenu(null); + draftKindRef.current = null; setDraftKind(null); - setSelectedItem(readSidebarSelectionFromUrl()); + const target = readSidebarSelectionFromUrl(); + selectedItemRef.current = target; + setSelectedItem(target); setIsMobileSidebarOpen(false); }; window.addEventListener("popstate", onPopState); @@ -1410,6 +1493,29 @@ export default function App() { const isItemRunning = (item: SidebarSelection) => item.kind === "chat" ? !!pendingChatStates[item.id] || !!activeRuns.chats[item.id] : !!runningSearchStates[item.id] || !!activeRuns.searches[item.id]; + const getChatFamilyItems = (chatId: string) => { + const chat = sidebarItems.find((item) => item.kind === "chat" && item.id === chatId); + if (!chat) return []; + const rootId = chat.parentChatId ?? chat.id; + return sidebarItems.filter( + (item) => item.kind === "chat" && (item.id === rootId || item.parentChatId === rootId) + ); + }; + const isRootChat = (item: SidebarSelection) => { + if (item.kind !== "chat") return false; + const chat = sidebarItems.find((candidate) => candidate.kind === "chat" && candidate.id === item.id); + return chat?.parentChatId === null; + }; + const isDeleteBlocked = (item: SidebarSelection) => { + if (!isRootChat(item)) return isItemRunning(item); + return getChatFamilyItems(item.id).some((familyItem) => isItemRunning(familyItem)); + }; + const isSidebarTargetStarred = (item: SidebarSelection) => { + if (item.kind === "chat") { + return getChatFamilyItems(item.id).find((familyItem) => familyItem.parentChatId === null)?.starred ?? false; + } + return sidebarItems.find((candidate) => candidate.kind === "search" && candidate.id === item.id)?.starred ?? false; + }; const isCurrentSelection = (item: SidebarSelection) => { const current = selectedItemRef.current; return current?.kind === item.kind && current.id === item.id; @@ -1428,6 +1534,10 @@ export default function App() { selectedItemRef.current = selectedItem; }, [selectedItem]); + useEffect(() => { + draftKindRef.current = draftKind; + }, [draftKind]); + useEffect(() => { if (!isAuthenticated) { setSelectedChat(null); @@ -1597,7 +1707,7 @@ export default function App() { if (draftKind === "search") return "New search"; if (!selectedItem) return "Sybil"; if (selectedItem.kind === "chat") { - if (selectedChat) return getChatTitle(selectedChat, selectedChat.messages); + if (selectedChat?.id === selectedItem.id) return getChatTitle(selectedChat, selectedChat.messages); if (selectedChatSummary) return getChatTitle(selectedChatSummary); return "New chat"; } @@ -1609,7 +1719,7 @@ export default function App() { const pageTitle = useMemo(() => { if (draftKind || !selectedItem) return "Sybil"; if (selectedItem.kind === "chat") { - if (selectedChat) return `${getChatTitle(selectedChat, selectedChat.messages)} โ€” Sybil`; + if (selectedChat?.id === selectedItem.id) return `${getChatTitle(selectedChat, selectedChat.messages)} โ€” Sybil`; if (selectedChatSummary) return `${getChatTitle(selectedChatSummary)} โ€” Sybil`; return "Sybil"; } @@ -1631,6 +1741,7 @@ export default function App() { const handleCreateChat = () => { setError(null); setContextMenu(null); + draftKindRef.current = "chat"; setDraftKind("chat"); selectedItemRef.current = null; setSelectedItem(null); @@ -1768,6 +1879,7 @@ export default function App() { const handleCreateSearch = () => { setError(null); setContextMenu(null); + draftKindRef.current = "search"; setDraftKind("search"); selectedItemRef.current = null; setSelectedItem(null); @@ -1780,24 +1892,26 @@ export default function App() { }; const selectAdjacentSidebarItem = (direction: -1 | 1) => { - if (!filteredSidebarItems.length) return; + if (!orderedSidebarItems.length) return; setError(null); setContextMenu(null); + draftKindRef.current = null; setDraftKind(null); setIsMobileSidebarOpen(false); - setSelectedItem((current) => { - const currentIndex = current - ? filteredSidebarItems.findIndex((item) => item.kind === current.kind && item.id === current.id) - : -1; - const fallbackIndex = direction > 0 ? 0 : filteredSidebarItems.length - 1; - const nextIndex = - currentIndex < 0 - ? fallbackIndex - : Math.min(filteredSidebarItems.length - 1, Math.max(0, currentIndex + direction)); - const nextItem = filteredSidebarItems[nextIndex]; - return { kind: nextItem.kind, id: nextItem.id }; - }); + const current = selectedItemRef.current; + const currentIndex = current + ? orderedSidebarItems.findIndex((item) => item.kind === current.kind && item.id === current.id) + : -1; + const fallbackIndex = direction > 0 ? 0 : orderedSidebarItems.length - 1; + const nextIndex = + currentIndex < 0 + ? fallbackIndex + : Math.min(orderedSidebarItems.length - 1, Math.max(0, currentIndex + direction)); + const nextItem = orderedSidebarItems[nextIndex]; + const target: SidebarSelection = { kind: nextItem.kind, id: nextItem.id }; + selectedItemRef.current = target; + setSelectedItem(target); }; useEffect(() => { @@ -1836,7 +1950,7 @@ export default function App() { window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [filteredSidebarItems, isAuthenticated, isQuickQuestionOpen]); + }, [isAuthenticated, isQuickQuestionOpen, orderedSidebarItems]); const getRenameSeedTitle = (chatId: string) => { if (selectedChat?.id === chatId) return getChatTitle(selectedChat, selectedChat.messages); @@ -1847,6 +1961,7 @@ export default function App() { }; const applyChatSummary = (updatedChat: ChatSummary, moveToFront = true) => { + supersedeCollectionLoad(); setChats((current) => { const withoutExisting = current.filter((chat) => chat.id !== updatedChat.id); if (moveToFront) return [updatedChat, ...withoutExisting]; @@ -1862,6 +1977,8 @@ export default function App() { return { ...current, title: updatedChat.title, + parentChatId: updatedChat.parentChatId, + titleGenerationPending: updatedChat.titleGenerationPending, updatedAt: updatedChat.updatedAt, starred: updatedChat.starred, starredAt: updatedChat.starredAt, @@ -1876,6 +1993,7 @@ export default function App() { }; const applySearchSummary = (updatedSearch: SearchSummary, moveToFront = true) => { + supersedeCollectionLoad(); setSearches((current) => { const withoutExisting = current.filter((search) => search.id !== updatedSearch.id); if (moveToFront) return [updatedSearch, ...withoutExisting]; @@ -1982,6 +2100,7 @@ export default function App() { }); applyChatSummary(updatedChat); } else if (!selectedItem && draftKind !== "chat") { + draftKindRef.current = "chat"; setDraftKind("chat"); } @@ -2002,11 +2121,28 @@ export default function App() { const openContextMenu = (event: MouseEvent, item: SidebarSelection) => { event.preventDefault(); const menuWidth = 176; - const menuHeight = item.kind === "chat" ? 120 : 80; + const menuHeight = item.kind === "chat" ? 152 : 80; const padding = 8; const x = Math.min(event.clientX, window.innerWidth - menuWidth - padding); const y = Math.min(event.clientY, window.innerHeight - menuHeight - padding); - setContextMenu({ item, x: Math.max(padding, x), y: Math.max(padding, y) }); + setContextMenu({ kind: "sidebar", item, x: Math.max(padding, x), y: Math.max(padding, y) }); + }; + + const openMessageContextMenu = (event: MouseEvent, messageId: string) => { + if (selectedItem?.kind !== "chat" || messageId.startsWith("temp-")) return; + event.preventDefault(); + const menuWidth = 176; + const menuHeight = 48; + const padding = 8; + const x = Math.min(event.clientX, window.innerWidth - menuWidth - padding); + const y = Math.min(event.clientY, window.innerHeight - menuHeight - padding); + setContextMenu({ + kind: "message", + chatId: selectedItem.id, + messageId, + x: Math.max(padding, x), + y: Math.max(padding, y), + }); }; const handleRenameChatSubmit = async (event?: Event) => { @@ -2039,18 +2175,23 @@ export default function App() { } }; + const toggleChatFamilyStar = async (chatId: string) => { + const family = getChatFamilyItems(chatId); + const root = family.find((item) => item.parentChatId === null) ?? family[0]; + if (!root) return; + applyChatSummary(await updateChatStar(root.id, !root.starred), false); + }; + const handleToggleStar = async (target: SidebarSelection) => { - const current = sidebarItems.find((item) => item.kind === target.kind && item.id === target.id); - const nextStarred = !current?.starred; setContextMenu(null); setError(null); try { if (target.kind === "chat") { - const updatedChat = await updateChatStar(target.id, nextStarred); - applyChatSummary(updatedChat, false); + await toggleChatFamilyStar(target.id); } else { - const updatedSearch = await updateSearchStar(target.id, nextStarred); + const current = sidebarItems.find((item) => item.kind === "search" && item.id === target.id); + const updatedSearch = await updateSearchStar(target.id, !current?.starred); applySearchSummary(updatedSearch, false); } } catch (err) { @@ -2063,17 +2204,57 @@ export default function App() { } }; + const handleForkChat = async (chatId: string, messageId?: string) => { + if (activeForkRequestRef.current !== null) return; + const requestId = ++forkRequestSequenceRef.current; + const selectionAtStart = selectedItemRef.current; + const draftKindAtStart = draftKindRef.current; + activeForkRequestRef.current = requestId; + setIsForkingChat(true); + setContextMenu(null); + setError(null); + try { + const forkedChat = await forkChat(chatId, messageId); + if (activeForkRequestRef.current !== requestId) return; + const target: SidebarSelection = { kind: "chat", id: forkedChat.id }; + applyChatSummary(forkedChat); + const currentSelection = selectedItemRef.current; + const selectionUnchanged = selectionAtStart + ? currentSelection?.kind === selectionAtStart.kind && currentSelection.id === selectionAtStart.id + : currentSelection === null; + if (!selectionUnchanged || draftKindRef.current !== draftKindAtStart) return; + + draftKindRef.current = null; + setDraftKind(null); + selectedItemRef.current = target; + setSelectedItem(target); + setSelectedChat(null); + setSelectedSearch(null); + setIsMobileSidebarOpen(false); + } catch (err) { + if (activeForkRequestRef.current !== requestId) return; + const message = err instanceof Error ? err.message : String(err); + if (message.includes("bearer token")) { + handleAuthFailure(message); + } else { + setError(message); + } + } finally { + if (activeForkRequestRef.current === requestId) { + activeForkRequestRef.current = null; + setIsForkingChat(false); + } + } + }; + const handleToggleChatSettingsStar = async () => { if (draftKind !== null || selectedItem?.kind !== "chat" || isTogglingChatSettingsStar) return; - const current = sidebarItems.find((item) => item.kind === "chat" && item.id === selectedItem.id); - const nextStarred = !current?.starred; setIsTogglingChatSettingsStar(true); setChatSettingsError(null); setError(null); try { - const updatedChat = await updateChatStar(selectedItem.id, nextStarred); - applyChatSummary(updatedChat, false); + await toggleChatFamilyStar(selectedItem.id); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { @@ -2087,9 +2268,13 @@ export default function App() { }; const handleDeleteFromContextMenu = async () => { - if (!contextMenu || isItemRunning(contextMenu.item)) return; + if (!contextMenu || contextMenu.kind !== "sidebar" || isDeleteBlocked(contextMenu.item)) return; const target = contextMenu.item; setContextMenu(null); + if (isRootChat(target)) { + const confirmed = window.confirm("Delete this chat and all of its forks? This cannot be undone."); + if (!confirmed) return; + } setError(null); try { if (target.kind === "chat") { @@ -2314,6 +2499,8 @@ export default function App() { ...(initialEnabledTools !== undefined ? { enabledTools: initialEnabledTools } : {}), }); chatId = chat.id; + supersedeCollectionLoad(); + draftKindRef.current = null; setDraftKind(null); setDraftChatTitle(""); setChats((current) => { @@ -2321,10 +2508,14 @@ export default function App() { return [chat, ...withoutExisting]; }); setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat))); - setSelectedItem({ kind: "chat", id: chatId }); + const createdChatSelection: SidebarSelection = { kind: "chat", id: chatId }; + selectedItemRef.current = createdChatSelection; + setSelectedItem(createdChatSelection); setSelectedChat({ id: chat.id, title: chat.title, + parentChatId: chat.parentChatId, + titleGenerationPending: chat.titleGenerationPending, createdAt: chat.createdAt, updatedAt: chat.updatedAt, starred: chat.starred, @@ -2378,24 +2569,12 @@ export default function App() { }, ]; - const chatSummary = chats.find((chat) => chat.id === chatId); - const hasExistingTitle = Boolean(selectedChat?.id === chatId ? selectedChat.title?.trim() : chatSummary?.title?.trim()); - if (!hasExistingTitle && !pendingTitleGenerationRef.current.has(chatId)) { + if (shouldRequestChatTitle(baseChat, pendingTitleGenerationRef.current.has(chatId))) { pendingTitleGenerationRef.current.add(chatId); const titleSeed = content || buildAttachmentSummary(attachments) || "Uploaded files"; void suggestChatTitle({ chatId, content: titleSeed }) .then((updatedChat) => { - setChats((current) => - current.map((chat) => { - if (chat.id !== updatedChat.id) return chat; - return { ...chat, title: updatedChat.title, updatedAt: updatedChat.updatedAt }; - }) - ); - setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(updatedChat), false)); - setSelectedChat((current) => { - if (!current || current.id !== updatedChat.id) return current; - return { ...current, title: updatedChat.title, updatedAt: updatedChat.updatedAt }; - }); + applyChatSummary(updatedChat, false); }) .catch(() => { // ignore title suggestion errors so chat flow is not interrupted @@ -2568,8 +2747,12 @@ export default function App() { title: query.slice(0, 80), }); searchId = search.id; + supersedeCollectionLoad(); + draftKindRef.current = null; setDraftKind(null); - setSelectedItem({ kind: "search", id: searchId }); + const createdSearchSelection: SidebarSelection = { kind: "search", id: searchId }; + selectedItemRef.current = createdSearchSelection; + setSelectedItem(createdSearchSelection); setSearches((current) => { const withoutExisting = current.filter((existing) => existing.id !== search.id); return [search, ...withoutExisting]; @@ -2964,6 +3147,8 @@ export default function App() { setIsStartingSearchChat(true); try { const chat = await createChatFromSearch(sourceSearch.id); + supersedeCollectionLoad(); + draftKindRef.current = null; setDraftKind(null); replaceComposerDraft(""); setPendingAttachments([]); @@ -2972,10 +3157,14 @@ export default function App() { return [chat, ...withoutExisting]; }); setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat))); - setSelectedItem({ kind: "chat", id: chat.id }); + const createdChatSelection: SidebarSelection = { kind: "chat", id: chat.id }; + selectedItemRef.current = createdChatSelection; + setSelectedItem(createdChatSelection); setSelectedChat({ id: chat.id, title: chat.title, + parentChatId: chat.parentChatId, + titleGenerationPending: chat.titleGenerationPending, createdAt: chat.createdAt, updatedAt: chat.updatedAt, starred: chat.starred, @@ -3123,6 +3312,8 @@ export default function App() { ], }); + supersedeCollectionLoad(); + draftKindRef.current = null; setDraftKind(null); replaceComposerDraft(""); setPendingAttachments([]); @@ -3134,10 +3325,14 @@ export default function App() { return [chat, ...withoutExisting]; }); setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat))); - setSelectedItem({ kind: "chat", id: chat.id }); + const createdChatSelection: SidebarSelection = { kind: "chat", id: chat.id }; + selectedItemRef.current = createdChatSelection; + setSelectedItem(createdChatSelection); setSelectedChat({ id: chat.id, title: chat.title, + parentChatId: chat.parentChatId, + titleGenerationPending: chat.titleGenerationPending, createdAt: chat.createdAt, updatedAt: chat.updatedAt, starred: chat.starred, @@ -3218,9 +3413,7 @@ export default function App() { }; const chatSettingsChatId = draftKind === null && selectedItem?.kind === "chat" ? selectedItem.id : null; - const chatSettingsStarred = chatSettingsChatId - ? sidebarItems.find((item) => item.kind === "chat" && item.id === chatSettingsChatId)?.starred ?? false - : false; + const chatSettingsStarred = chatSettingsChatId ? isSidebarTargetStarred({ kind: "chat", id: chatSettingsChatId }) : false; if (isCheckingSession) { return ( @@ -3335,7 +3528,18 @@ export default function App() {

{section.label}

{section.items.map((item) => { const active = selectedItem?.kind === item.kind && selectedItem.id === item.id; - const itemIsRunning = isItemRunning(item); + const isForkChild = item.kind === "chat" && item.parentChatId !== null; + const familyItems = item.kind === "chat" ? getChatFamilyItems(item.id) : [item]; + const itemIsRunning = familyItems.some((familyItem) => isItemRunning(familyItem)); + const itemIsStarred = + familyItems.find((familyItem) => familyItem.parentChatId === null)?.starred ?? false; + const itemUpdatedAt = familyItems.reduce( + (newest, familyItem) => + new Date(familyItem.updatedAt).getTime() > new Date(newest).getTime() + ? familyItem.updatedAt + : newest, + item.updatedAt + ); const initiatedLabel = item.kind === "chat" && item.initiatedModel ? `${getProviderLabel(item.initiatedProvider)}${item.initiatedProvider ? " ยท " : ""}${item.initiatedModel}` : null; @@ -3343,53 +3547,62 @@ export default function App() { ); })} @@ -3461,7 +3674,12 @@ export default function App() { }} > {!isSearchMode ? ( - + ) : ( event.preventDefault()} > - - {contextMenu.item.kind === "chat" ? ( + {contextMenu.kind === "message" ? ( - ) : null} - + ) : ( + <> + + {contextMenu.item.kind === "chat" ? ( + <> + + + + ) : null} + + + )} ) : null} {isChatSettingsOpen ? ( diff --git a/web/src/components/chat/chat-messages-panel.tsx b/web/src/components/chat/chat-messages-panel.tsx index f03d928..0886470 100644 --- a/web/src/components/chat/chat-messages-panel.tsx +++ b/web/src/components/chat/chat-messages-panel.tsx @@ -10,6 +10,7 @@ type Props = { messages: Message[]; isLoading: boolean; isSending: boolean; + onMessageContextMenu?: (event: MouseEvent, messageId: string) => void; }; type ToolLogMetadata = { @@ -395,7 +396,7 @@ function ToolCallStack({ ); } -export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) { +export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageContextMenu }: Props) { const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0); const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]); const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]); @@ -467,6 +468,11 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) { ? "rounded-xl border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm" : "text-base leading-7 text-violet-50" )} + onContextMenu={ + message.role === "assistant" && !message.id.startsWith("temp-") && onMessageContextMenu + ? (event) => onMessageContextMenu(event, message.id) + : undefined + } > {attachments.length ? : null} {isPendingAssistant ? ( diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 75724d7..977514e 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,6 +1,8 @@ export type ChatSummary = { id: string; title: string | null; + parentChatId: string | null; + titleGenerationPending: boolean; createdAt: string; updatedAt: string; starred: boolean; @@ -58,6 +60,8 @@ export type ToolCallEvent = { export type ChatDetail = { id: string; title: string | null; + parentChatId: string | null; + titleGenerationPending: boolean; createdAt: string; updatedAt: string; starred: boolean; @@ -291,6 +295,14 @@ export async function getChat(chatId: string) { return data.chat; } +export async function forkChat(chatId: string, messageId?: string) { + const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}/fork`, { + method: "POST", + body: JSON.stringify(messageId ? { messageId } : {}), + }); + return data.chat; +} + export async function updateChatTitle(chatId: string, title: string) { const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, { method: "PATCH", diff --git a/web/src/lib/chat-forking.ts b/web/src/lib/chat-forking.ts new file mode 100644 index 0000000..03b3154 --- /dev/null +++ b/web/src/lib/chat-forking.ts @@ -0,0 +1,82 @@ +export type ForkGroupingItem = { + kind: "chat" | "search"; + id: string; + parentChatId: string | null; +}; + +type ChatTitleState = { + title: string | null; + titleGenerationPending: boolean; +}; + +type ForkPresentationItem = ForkGroupingItem & { + updatedAt: string; + starred: boolean; + starredAt: string | null; +}; + +export function groupForkedChatItems(items: T[]): T[][] { + const groups = new Map }>(); + + items.forEach((item, index) => { + const groupKey = item.kind === "chat" ? `chat:${item.parentChatId ?? item.id}` : `search:${item.id}`; + const group = groups.get(groupKey); + if (group) { + group.items.push({ item, index }); + return; + } + groups.set(groupKey, { firstIndex: index, items: [{ item, index }] }); + }); + + return [...groups.values()] + .sort((a, b) => a.firstIndex - b.firstIndex) + .map((group) => + group.items + .sort((a, b) => { + const aIsRoot = a.item.kind === "chat" && a.item.parentChatId === null; + const bIsRoot = b.item.kind === "chat" && b.item.parentChatId === null; + if (aIsRoot !== bIsRoot) return aIsRoot ? -1 : 1; + return a.index - b.index; + }) + .map(({ item }) => item) + ); +} + +export function filterSidebarItemsWithForkGroups(items: T[], matches: (item: T) => boolean): T[] { + return groupForkedChatItems(items) + .flatMap((group) => { + const matchingItems = group.filter(matches); + if (!matchingItems.length) return []; + const root = group.find((item) => item.kind === "chat" && item.parentChatId === null); + if (!root || matchingItems.includes(root)) return group; + return group.filter((item) => item === root || matchingItems.includes(item)); + }); +} + +export function getForkGroupPresentation(groupItems: T[], allItems: T[]) { + const firstItem = groupItems[0]; + if (!firstItem) return null; + + const rootId = firstItem.kind === "chat" ? firstItem.parentChatId ?? firstItem.id : null; + const familyItems = rootId + ? allItems.filter((item) => item.kind === "chat" && (item.id === rootId || item.parentChatId === rootId)) + : groupItems; + const updatedAt = familyItems.reduce( + (newest, item) => (new Date(item.updatedAt).getTime() > new Date(newest).getTime() ? item.updatedAt : newest), + firstItem.updatedAt + ); + const starOwner = rootId + ? familyItems.find((item) => item.kind === "chat" && item.parentChatId === null) + : firstItem; + + return { + updatedAt, + starred: starOwner?.starred ?? false, + starredAt: starOwner?.starredAt ?? null, + }; +} + +export function shouldRequestChatTitle(chat: ChatTitleState | null | undefined, isRequestInFlight: boolean) { + if (!chat || isRequestInFlight) return false; + return chat.titleGenerationPending || !chat.title?.trim(); +} diff --git a/web/tests/chat-forking.test.mjs b/web/tests/chat-forking.test.mjs new file mode 100644 index 0000000..5a674f2 --- /dev/null +++ b/web/tests/chat-forking.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + filterSidebarItemsWithForkGroups, + getForkGroupPresentation, + groupForkedChatItems, + shouldRequestChatTitle, +} from "../src/lib/chat-forking.ts"; + +const root = { kind: "chat", id: "root", parentChatId: null }; +const firstFork = { kind: "chat", id: "first-fork", parentChatId: "root" }; +const nestedFork = { kind: "chat", id: "nested-fork", parentChatId: "root" }; +const search = { kind: "search", id: "search", parentChatId: null }; + +test("fork groups are positioned by their newest member and always render root first", () => { + assert.deepEqual(groupForkedChatItems([nestedFork, search, root, firstFork]), [ + [root, nestedFork, firstFork], + [search], + ]); +}); + +test("sidebar filtering keeps a matching fork with its root", () => { + assert.deepEqual( + filterSidebarItemsWithForkGroups([search, root, firstFork, nestedFork], (item) => item.id === "nested-fork"), + [root, nestedFork] + ); +}); + +test("sidebar filtering keeps all children when their root matches", () => { + assert.deepEqual( + filterSidebarItemsWithForkGroups([search, root, firstFork, nestedFork], (item) => item.id === "root"), + [root, firstFork, nestedFork] + ); +}); + +test("a filtered family keeps its full-family date and root-owned star state", () => { + const fullFamily = [ + { ...root, updatedAt: "2026-08-10T00:00:00.000Z", starred: true, starredAt: "2026-08-15T00:00:00.000Z" }, + { ...firstFork, updatedAt: "2026-08-11T00:00:00.000Z", starred: false, starredAt: null }, + { ...nestedFork, updatedAt: "2026-08-16T00:00:00.000Z", starred: false, starredAt: null }, + ]; + const filteredFamily = [fullFamily[0], fullFamily[1]]; + + assert.deepEqual(getForkGroupPresentation(filteredFamily, fullFamily), { + updatedAt: "2026-08-16T00:00:00.000Z", + starred: true, + starredAt: "2026-08-15T00:00:00.000Z", + }); +}); + +test("a fork placeholder title is replaced on its first submitted prompt", () => { + assert.equal(shouldRequestChatTitle({ title: "Fork of original", titleGenerationPending: true }, false), true); + assert.equal(shouldRequestChatTitle({ title: "Generated title", titleGenerationPending: false }, false), false); +}); + +test("title generation remains compatible with untitled chats and deduplicates in-flight requests", () => { + assert.equal(shouldRequestChatTitle({ title: null, titleGenerationPending: false }, false), true); + assert.equal(shouldRequestChatTitle({ title: "Fork of original", titleGenerationPending: true }, true), false); +}); diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo index 4ccc5bd..16587b6 100644 --- a/web/tsconfig.tsbuildinfo +++ b/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-composer.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/chat-model-selection.ts","./src/lib/sidebar-selection.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-composer.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/chat-forking.ts","./src/lib/chat-model-selection.ts","./src/lib/sidebar-selection.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"} \ No newline at end of file