| /** | |
| * Message branching utilities for conversation tree navigation. | |
| * | |
| * Conversation branching allows users to edit messages and create alternate paths | |
| * while preserving the original conversation flow. Each message has parent/children | |
| * relationships forming a tree structure. | |
| * | |
| * Example tree: | |
| * root | |
| * βββ message 1 (user) | |
| * β βββ message 2 (assistant) | |
| * β βββ message 3 (user) | |
| * β βββ message 6 (user) β new branch | |
| * βββ message 4 (user) | |
| * βββ message 5 (assistant) | |
| */ | |
| /** | |
| * Filters messages to get the conversation path from root to a specific leaf node. | |
| * If the leafNodeId doesn't exist, returns the path with the latest timestamp. | |
| * | |
| * @param messages - All messages in the conversation | |
| * @param leafNodeId - The target leaf node ID to trace back from | |
| * @param includeRoot - Whether to include root messages in the result | |
| * @returns Array of messages from root to leaf, sorted by timestamp | |
| */ | |
| export function filterByLeafNodeId( | |
| messages: readonly DatabaseMessage[], | |
| leafNodeId: string, | |
| includeRoot: boolean = false | |
| ): readonly DatabaseMessage[] { | |
| const result: DatabaseMessage[] = []; | |
| const nodeMap = new Map<string, DatabaseMessage>(); | |
| // Build node map for quick lookups | |
| for (const msg of messages) { | |
| nodeMap.set(msg.id, msg); | |
| } | |
| // Find the starting node (leaf node or latest if not found) | |
| let startNode: DatabaseMessage | undefined = nodeMap.get(leafNodeId); | |
| if (!startNode) { | |
| // If leaf node not found, use the message with latest timestamp | |
| let latestTime = -1; | |
| for (const msg of messages) { | |
| if (msg.timestamp > latestTime) { | |
| startNode = msg; | |
| latestTime = msg.timestamp; | |
| } | |
| } | |
| } | |
| // Traverse from leaf to root, collecting messages | |
| let currentNode: DatabaseMessage | undefined = startNode; | |
| while (currentNode) { | |
| // Include message if it's not root, or if we want to include root | |
| if (currentNode.type !== 'root' || includeRoot) { | |
| result.push(currentNode); | |
| } | |
| // Stop traversal if parent is null (reached root) | |
| if (currentNode.parent === null) { | |
| break; | |
| } | |
| currentNode = nodeMap.get(currentNode.parent); | |
| } | |
| // Sort by timestamp to get chronological order (root to leaf) | |
| result.sort((a, b) => a.timestamp - b.timestamp); | |
| return result; | |
| } | |
| /** | |
| * Finds the leaf node (message with no children) for a given message branch. | |
| * Traverses down the tree following the last child until reaching a leaf. | |
| * | |
| * @param messages - All messages in the conversation | |
| * @param messageId - Starting message ID to find leaf for | |
| * @returns The leaf node ID, or the original messageId if no children | |
| */ | |
| export function findLeafNode(messages: readonly DatabaseMessage[], messageId: string): string { | |
| const nodeMap = new Map<string, DatabaseMessage>(); | |
| // Build node map for quick lookups | |
| for (const msg of messages) { | |
| nodeMap.set(msg.id, msg); | |
| } | |
| let currentNode: DatabaseMessage | undefined = nodeMap.get(messageId); | |
| while (currentNode && currentNode.children.length > 0) { | |
| // Follow the last child (most recent branch) | |
| const lastChildId = currentNode.children[currentNode.children.length - 1]; | |
| currentNode = nodeMap.get(lastChildId); | |
| } | |
| return currentNode?.id ?? messageId; | |
| } | |
| /** | |
| * Finds all descendant messages (children, grandchildren, etc.) of a given message. | |
| * This is used for cascading deletion to remove all messages in a branch. | |
| * | |
| * @param messages - All messages in the conversation | |
| * @param messageId - The root message ID to find descendants for | |
| * @returns Array of all descendant message IDs | |
| */ | |
| export function findDescendantMessages( | |
| messages: readonly DatabaseMessage[], | |
| messageId: string | |
| ): string[] { | |
| const nodeMap = new Map<string, DatabaseMessage>(); | |
| // Build node map for quick lookups | |
| for (const msg of messages) { | |
| nodeMap.set(msg.id, msg); | |
| } | |
| const descendants: string[] = []; | |
| const queue: string[] = [messageId]; | |
| while (queue.length > 0) { | |
| const currentId = queue.shift()!; | |
| const currentNode = nodeMap.get(currentId); | |
| if (currentNode) { | |
| // Add all children to the queue and descendants list | |
| for (const childId of currentNode.children) { | |
| descendants.push(childId); | |
| queue.push(childId); | |
| } | |
| } | |
| } | |
| return descendants; | |
| } | |
| /** | |
| * Gets sibling information for a message, including all sibling IDs and current position. | |
| * Siblings are messages that share the same parent. | |
| * | |
| * @param messages - All messages in the conversation | |
| * @param messageId - The message to get sibling info for | |
| * @returns Sibling information including leaf node IDs for navigation | |
| */ | |
| export function getMessageSiblings( | |
| messages: readonly DatabaseMessage[], | |
| messageId: string | |
| ): ChatMessageSiblingInfo | null { | |
| const nodeMap = new Map<string, DatabaseMessage>(); | |
| // Build node map for quick lookups | |
| for (const msg of messages) { | |
| nodeMap.set(msg.id, msg); | |
| } | |
| const message = nodeMap.get(messageId); | |
| if (!message) { | |
| return null; | |
| } | |
| // Handle null parent (root message) case | |
| if (message.parent === null) { | |
| // No parent means this is likely a root node with no siblings | |
| return { | |
| message, | |
| siblingIds: [messageId], | |
| currentIndex: 0, | |
| totalSiblings: 1 | |
| }; | |
| } | |
| const parentNode = nodeMap.get(message.parent); | |
| if (!parentNode) { | |
| // Parent not found - treat as single message | |
| return { | |
| message, | |
| siblingIds: [messageId], | |
| currentIndex: 0, | |
| totalSiblings: 1 | |
| }; | |
| } | |
| // Get all sibling IDs (including self) | |
| const siblingIds = parentNode.children; | |
| // Convert sibling message IDs to their corresponding leaf node IDs | |
| // This allows navigation between different conversation branches | |
| const siblingLeafIds = siblingIds.map((siblingId: string) => findLeafNode(messages, siblingId)); | |
| // Find current message's position among siblings | |
| const currentIndex = siblingIds.indexOf(messageId); | |
| return { | |
| message, | |
| siblingIds: siblingLeafIds, | |
| currentIndex, | |
| totalSiblings: siblingIds.length | |
| }; | |
| } | |
| /** | |
| * Creates a display-ready list of messages with sibling information for UI rendering. | |
| * This is the main function used by chat components to render conversation branches. | |
| * | |
| * @param messages - All messages in the conversation | |
| * @param leafNodeId - Current leaf node being viewed | |
| * @returns Array of messages with sibling navigation info | |
| */ | |
| export function getMessageDisplayList( | |
| messages: readonly DatabaseMessage[], | |
| leafNodeId: string | |
| ): ChatMessageSiblingInfo[] { | |
| // Get the current conversation path | |
| const currentPath = filterByLeafNodeId(messages, leafNodeId, true); | |
| const result: ChatMessageSiblingInfo[] = []; | |
| // Add sibling info for each message in the current path | |
| for (const message of currentPath) { | |
| if (message.type === 'root') { | |
| continue; // Skip root messages in display | |
| } | |
| const siblingInfo = getMessageSiblings(messages, message.id); | |
| if (siblingInfo) { | |
| result.push(siblingInfo); | |
| } | |
| } | |
| return result; | |
| } | |
| /** | |
| * Checks if a message has multiple siblings (indicating branching at that point). | |
| * | |
| * @param messages - All messages in the conversation | |
| * @param messageId - The message to check | |
| * @returns True if the message has siblings | |
| */ | |
| export function hasMessageSiblings( | |
| messages: readonly DatabaseMessage[], | |
| messageId: string | |
| ): boolean { | |
| const siblingInfo = getMessageSiblings(messages, messageId); | |
| return siblingInfo ? siblingInfo.totalSiblings > 1 : false; | |
| } | |
| /** | |
| * Gets the next sibling message ID for navigation. | |
| * | |
| * @param messages - All messages in the conversation | |
| * @param messageId - Current message ID | |
| * @returns Next sibling's leaf node ID, or null if at the end | |
| */ | |
| export function getNextSibling( | |
| messages: readonly DatabaseMessage[], | |
| messageId: string | |
| ): string | null { | |
| const siblingInfo = getMessageSiblings(messages, messageId); | |
| if (!siblingInfo || siblingInfo.currentIndex >= siblingInfo.totalSiblings - 1) { | |
| return null; | |
| } | |
| return siblingInfo.siblingIds[siblingInfo.currentIndex + 1]; | |
| } | |
| /** | |
| * Gets the previous sibling message ID for navigation. | |
| * | |
| * @param messages - All messages in the conversation | |
| * @param messageId - Current message ID | |
| * @returns Previous sibling's leaf node ID, or null if at the beginning | |
| */ | |
| export function getPreviousSibling( | |
| messages: readonly DatabaseMessage[], | |
| messageId: string | |
| ): string | null { | |
| const siblingInfo = getMessageSiblings(messages, messageId); | |
| if (!siblingInfo || siblingInfo.currentIndex <= 0) { | |
| return null; | |
| } | |
| return siblingInfo.siblingIds[siblingInfo.currentIndex - 1]; | |
| } | |