@aleph-alpha/chat-kit 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/dist/{CkInput.vue_vue_type_script_setup_true_lang-744ZHsQU.js → CkInput.vue_vue_type_script_setup_true_lang-DpYyyCgU.js} +23 -68
  2. package/dist/components/base/CkHistoryItem/CkHistoryItem.vue.d.ts +3 -4
  3. package/dist/components/base/CkHistoryItem/CkHistoryItem.vue.d.ts.map +1 -1
  4. package/dist/components/base/CkHistoryItem/types.d.ts +3 -2
  5. package/dist/components/base/CkHistoryItem/types.d.ts.map +1 -1
  6. package/dist/components/base/CkPromptStopButton/CkPromptStopButton.vue.d.ts.map +1 -1
  7. package/dist/components/index.js +1 -1
  8. package/dist/index.js +1 -1
  9. package/package.json +5 -5
  10. package/src/components/base/CkAgentMessage/CkAgentMessage.spec.ts +91 -0
  11. package/src/components/base/CkAgentMessage/CkAgentMessage.stories.ts +215 -0
  12. package/src/components/base/CkAgentMessage/CkAgentMessage.vue +25 -0
  13. package/src/components/base/CkAgentMessage/index.ts +2 -0
  14. package/src/components/base/CkAgentMessage/types.ts +11 -0
  15. package/src/components/base/CkConversationLayout/CkConversationLayout.spec.ts +129 -0
  16. package/src/components/base/CkConversationLayout/CkConversationLayout.stories.ts +251 -0
  17. package/src/components/base/CkConversationLayout/CkConversationLayout.vue +206 -0
  18. package/src/components/base/CkConversationLayout/index.ts +2 -0
  19. package/src/components/base/CkConversationLayout/types.ts +18 -0
  20. package/src/components/base/CkCopyMessageActionButton/CkCopyMessageActionButton.spec.ts +89 -0
  21. package/src/components/base/CkCopyMessageActionButton/CkCopyMessageActionButton.stories.ts +70 -0
  22. package/src/components/base/CkCopyMessageActionButton/CkCopyMessageActionButton.vue +110 -0
  23. package/src/components/base/CkCopyMessageActionButton/index.ts +2 -0
  24. package/src/components/base/CkCopyMessageActionButton/types.ts +43 -0
  25. package/src/components/base/CkHistoryItem/CkHistoryItem.spec.ts +42 -0
  26. package/src/components/base/CkHistoryItem/CkHistoryItem.stories.ts +230 -0
  27. package/src/components/base/CkHistoryItem/CkHistoryItem.vue +285 -0
  28. package/src/components/base/CkHistoryItem/activeIndicator.ts +15 -0
  29. package/src/components/base/CkHistoryItem/index.ts +2 -0
  30. package/src/components/base/CkHistoryItem/types.ts +111 -0
  31. package/src/components/base/CkHistoryWrapper/CkHistoryWrapper.spec.ts +26 -0
  32. package/src/components/base/CkHistoryWrapper/CkHistoryWrapper.stories.ts +149 -0
  33. package/src/components/base/CkHistoryWrapper/CkHistoryWrapper.vue +36 -0
  34. package/src/components/base/CkHistoryWrapper/index.ts +2 -0
  35. package/src/components/base/CkHistoryWrapper/types.ts +16 -0
  36. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.spec.ts +348 -0
  37. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.ts +382 -0
  38. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue +89 -0
  39. package/src/components/base/CkMarkdownRenderer/defaultMarkdownClasses.ts +19 -0
  40. package/src/components/base/CkMarkdownRenderer/index.ts +5 -0
  41. package/src/components/base/CkMarkdownRenderer/types.ts +53 -0
  42. package/src/components/base/CkMessageActionButton/CkMessageActionButton.spec.ts +40 -0
  43. package/src/components/base/CkMessageActionButton/CkMessageActionButton.stories.ts +56 -0
  44. package/src/components/base/CkMessageActionButton/CkMessageActionButton.vue +23 -0
  45. package/src/components/base/CkMessageActionButton/index.ts +2 -0
  46. package/src/components/base/CkMessageActionButton/types.ts +21 -0
  47. package/src/components/base/CkPromptActionButton/CkPromptActionButton.spec.ts +52 -0
  48. package/src/components/base/CkPromptActionButton/CkPromptActionButton.stories.ts +126 -0
  49. package/src/components/base/CkPromptActionButton/CkPromptActionButton.vue +25 -0
  50. package/src/components/base/CkPromptActionButton/index.ts +2 -0
  51. package/src/components/base/CkPromptActionButton/types.ts +32 -0
  52. package/src/components/base/CkPromptInput/CkPromptInput.spec.ts +181 -0
  53. package/src/components/base/CkPromptInput/CkPromptInput.stories.ts +224 -0
  54. package/src/components/base/CkPromptInput/CkPromptInput.vue +104 -0
  55. package/src/components/base/CkPromptInput/index.ts +2 -0
  56. package/src/components/base/CkPromptInput/types.ts +39 -0
  57. package/src/components/base/CkPromptInputDisclaimer/CkPromptInputDisclaimer.spec.ts +40 -0
  58. package/src/components/base/CkPromptInputDisclaimer/CkPromptInputDisclaimer.stories.ts +103 -0
  59. package/src/components/base/CkPromptInputDisclaimer/CkPromptInputDisclaimer.vue +16 -0
  60. package/src/components/base/CkPromptInputDisclaimer/index.ts +2 -0
  61. package/src/components/base/CkPromptInputDisclaimer/types.ts +17 -0
  62. package/src/components/base/CkPromptStopButton/CkPromptStopButton.spec.ts +64 -0
  63. package/src/components/base/CkPromptStopButton/CkPromptStopButton.stories.ts +81 -0
  64. package/src/components/base/CkPromptStopButton/CkPromptStopButton.vue +27 -0
  65. package/src/components/base/CkPromptStopButton/index.ts +2 -0
  66. package/src/components/base/CkPromptStopButton/types.ts +28 -0
  67. package/src/components/base/CkUserMessage/CkUserMessage.spec.ts +20 -0
  68. package/src/components/base/CkUserMessage/CkUserMessage.stories.ts +112 -0
  69. package/src/components/base/CkUserMessage/CkUserMessage.vue +19 -0
  70. package/src/components/base/CkUserMessage/index.ts +2 -0
  71. package/src/components/base/CkUserMessage/types.ts +14 -0
  72. package/src/components/base/icons/CkSendIcon.vue +22 -0
  73. package/src/components/base/icons/CkStopIcon.vue +18 -0
  74. package/src/components/base/icons/index.ts +2 -0
  75. package/src/components/base/index.ts +21 -0
  76. package/src/components/composed/CkConversation/CkConversation.spec.ts +439 -0
  77. package/src/components/composed/CkConversation/CkConversation.stories.ts +642 -0
  78. package/src/components/composed/CkConversation/CkConversation.vue +146 -0
  79. package/src/components/composed/CkConversation/index.ts +2 -0
  80. package/src/components/composed/CkConversation/types.ts +37 -0
  81. package/src/components/composed/CkHistory/CkHistory.spec.ts +327 -0
  82. package/src/components/composed/CkHistory/CkHistory.stories.ts +373 -0
  83. package/src/components/composed/CkHistory/CkHistory.vue +126 -0
  84. package/src/components/composed/CkHistory/index.ts +2 -0
  85. package/src/components/composed/CkHistory/types.ts +72 -0
  86. package/src/components/composed/CkInput/CkInput.spec.ts +191 -0
  87. package/src/components/composed/CkInput/CkInput.stories.ts +438 -0
  88. package/src/components/composed/CkInput/CkInput.vue +104 -0
  89. package/src/components/composed/CkInput/index.ts +2 -0
  90. package/src/components/composed/CkInput/types.ts +60 -0
  91. package/src/components/composed/index.ts +5 -0
  92. package/src/components/index.ts +34 -0
  93. package/src/composables/index.ts +9 -0
  94. package/src/composables/types.ts +36 -0
  95. package/src/composables/useChat.ts +18 -0
  96. package/src/composables/useConversations.spec.ts +557 -0
  97. package/src/composables/useConversations.ts +321 -0
  98. package/src/handlers/index.ts +9 -0
  99. package/src/handlers/useChatKitLabels.spec.ts +290 -0
  100. package/src/handlers/useChatKitLabels.ts +96 -0
  101. package/src/handlers/useMarkdownParser.spec.ts +112 -0
  102. package/src/handlers/useMarkdownParser.ts +47 -0
  103. package/src/handlers/useMessageHandler.spec.ts +535 -0
  104. package/src/handlers/useMessageHandler.ts +138 -0
  105. package/src/helpers/conversationTree.spec.ts +423 -0
  106. package/src/helpers/conversationTree.ts +117 -0
  107. package/src/helpers/generateId.ts +5 -0
  108. package/src/helpers/index.ts +4 -0
  109. package/src/helpers/secondsToDate.ts +7 -0
  110. package/src/helpers/toError.ts +8 -0
  111. package/src/index.ts +79 -0
  112. package/src/services/ChatService.ts +60 -0
  113. package/src/services/MockChatService.ts +47 -0
  114. package/src/services/index.ts +12 -0
  115. package/src/services/types.ts +92 -0
  116. package/src/test-setup.ts +12 -0
  117. package/src/test-utils/createTextPlugin.ts +9 -0
  118. package/src/types.ts +47 -0
@@ -0,0 +1,138 @@
1
+ import {
2
+ generateId,
3
+ addNode as treeAddNode,
4
+ createEmptyTree,
5
+ getLinearPath,
6
+ navigate as treeNavigate,
7
+ updateNode,
8
+ updateNodeId,
9
+ } from '../helpers';
10
+ import type {
11
+ BaseMessage,
12
+ ConversationTree,
13
+ MessageRole,
14
+ MessageStatus,
15
+ TreeMessage,
16
+ } from '../types';
17
+ import { computed, reactive, readonly, ref, type Ref } from 'vue';
18
+
19
+ export const useMessageHandler = () => {
20
+ const tree = reactive<ConversationTree>(createEmptyTree());
21
+ const currentUserMessage = ref('');
22
+
23
+ const messages = computed<BaseMessage[]>(() => getLinearPath(tree));
24
+
25
+ const currentLeafId = computed<string | null>(() => {
26
+ const list = messages.value;
27
+ return list.length > 0 ? list[list.length - 1].id : null;
28
+ });
29
+
30
+ function setCurrentUserMessage(text: string) {
31
+ currentUserMessage.value = text;
32
+ }
33
+
34
+ function clearCurrentUserMessage() {
35
+ currentUserMessage.value = '';
36
+ }
37
+
38
+ function commitUserMessage(): BaseMessage {
39
+ const id = generateId();
40
+ const text = currentUserMessage.value.trim();
41
+ if (!text) throw new Error('No message');
42
+
43
+ const node: TreeMessage = {
44
+ id,
45
+ parentId: currentLeafId.value,
46
+ childrenIds: [],
47
+ activeChildIndex: 0,
48
+ role: 'user',
49
+ content: text,
50
+ status: 'completed',
51
+ createdAt: new Date(),
52
+ };
53
+
54
+ treeAddNode(tree, node);
55
+ clearCurrentUserMessage();
56
+
57
+ return {
58
+ id: node.id,
59
+ role: node.role,
60
+ content: node.content,
61
+ createdAt: node.createdAt,
62
+ status: node.status,
63
+ };
64
+ }
65
+
66
+ function updateMessage(message: {
67
+ id: string;
68
+ content: string;
69
+ role: MessageRole;
70
+ status: MessageStatus;
71
+ }) {
72
+ const existing = tree.nodes[message.id];
73
+
74
+ if (existing) {
75
+ updateNode(tree, message.id, {
76
+ content: message.content,
77
+ role: message.role,
78
+ status: message.status,
79
+ });
80
+ } else {
81
+ const node: TreeMessage = {
82
+ id: message.id,
83
+ parentId: currentLeafId.value,
84
+ childrenIds: [],
85
+ activeChildIndex: 0,
86
+ role: message.role,
87
+ content: message.content,
88
+ status: message.status,
89
+ createdAt: new Date(),
90
+ };
91
+ treeAddNode(tree, node);
92
+ }
93
+ }
94
+
95
+ function getMessageById(id: string): BaseMessage | undefined {
96
+ const node = tree.nodes[id];
97
+ if (!node) return undefined;
98
+ return {
99
+ id: node.id,
100
+ role: node.role,
101
+ content: node.content,
102
+ createdAt: node.createdAt,
103
+ status: node.status,
104
+ };
105
+ }
106
+
107
+ function updateMessageId(oldId: string, newId: string) {
108
+ updateNodeId(tree, oldId, newId);
109
+ }
110
+
111
+ function clearMessages() {
112
+ Object.assign(tree, createEmptyTree());
113
+ }
114
+
115
+ function setTree(incoming: ConversationTree) {
116
+ tree.nodes = incoming.nodes;
117
+ tree.rootId = incoming.rootId;
118
+ }
119
+
120
+ function navigate(targetId: string) {
121
+ treeNavigate(tree, targetId);
122
+ }
123
+
124
+ return {
125
+ messages: messages as Ref<BaseMessage[]>,
126
+ currentUserMessage: readonly(currentUserMessage),
127
+ currentLeafId,
128
+ updateMessage,
129
+ setCurrentUserMessage,
130
+ getMessageById,
131
+ clearCurrentUserMessage,
132
+ commitUserMessage,
133
+ updateMessageId,
134
+ clearMessages,
135
+ setTree,
136
+ navigate,
137
+ };
138
+ };
@@ -0,0 +1,423 @@
1
+ import type { ConversationTree, TreeMessage } from '../types';
2
+ import {
3
+ addNode,
4
+ createEmptyTree,
5
+ getLinearPath,
6
+ navigate,
7
+ updateNode,
8
+ updateNodeId,
9
+ } from './conversationTree';
10
+
11
+ function makeTreeMessage(
12
+ overrides: Partial<TreeMessage> & { id: string },
13
+ ): TreeMessage {
14
+ return {
15
+ parentId: null,
16
+ childrenIds: [],
17
+ activeChildIndex: 0,
18
+ role: 'user',
19
+ content: '',
20
+ status: 'completed',
21
+ createdAt: new Date('2025-01-01'),
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ function makeTree(
27
+ nodes: Record<string, TreeMessage>,
28
+ rootId: string,
29
+ ): ConversationTree {
30
+ for (const node of Object.values(nodes)) {
31
+ node.childrenIds = [];
32
+ }
33
+ for (const node of Object.values(nodes)) {
34
+ if (node.parentId !== null) {
35
+ const parent = nodes[node.parentId];
36
+ if (parent) parent.childrenIds.push(node.id);
37
+ }
38
+ }
39
+ for (const node of Object.values(nodes)) {
40
+ node.activeChildIndex = 0;
41
+ }
42
+ return { nodes, rootId };
43
+ }
44
+
45
+ /**
46
+ * Builds a simple branching tree for tests:
47
+ *
48
+ * root(u1)
49
+ * ├─ a1 (assistant, child 0) [activeChildIndex=0 → u2]
50
+ * │ └─ u2 [activeChildIndex=0 → a2]
51
+ * │ ├─ a2 (child 0)
52
+ * │ └─ a3 (child 1, regeneration of a2)
53
+ * └─ a1b (assistant, child 1, regeneration of a1)
54
+ * └─ u3
55
+ *
56
+ * Active path: u1 → a1 → u2 → a2 (activeChildIndex=0 everywhere)
57
+ */
58
+ function makeBranchingTree(): ConversationTree {
59
+ return makeTree(
60
+ {
61
+ u1: makeTreeMessage({ id: 'u1', role: 'user', content: 'Hello' }),
62
+ a1: makeTreeMessage({
63
+ id: 'a1',
64
+ parentId: 'u1',
65
+ role: 'assistant',
66
+ content: 'Hi',
67
+ }),
68
+ u2: makeTreeMessage({
69
+ id: 'u2',
70
+ parentId: 'a1',
71
+ role: 'user',
72
+ content: 'Question',
73
+ }),
74
+ a2: makeTreeMessage({
75
+ id: 'a2',
76
+ parentId: 'u2',
77
+ role: 'assistant',
78
+ content: 'Answer 1',
79
+ }),
80
+ a3: makeTreeMessage({
81
+ id: 'a3',
82
+ parentId: 'u2',
83
+ role: 'assistant',
84
+ content: 'Answer 2',
85
+ }),
86
+ a1b: makeTreeMessage({
87
+ id: 'a1b',
88
+ parentId: 'u1',
89
+ role: 'assistant',
90
+ content: 'Alt hi',
91
+ }),
92
+ u3: makeTreeMessage({
93
+ id: 'u3',
94
+ parentId: 'a1b',
95
+ role: 'user',
96
+ content: 'Follow-up',
97
+ }),
98
+ },
99
+ 'u1',
100
+ );
101
+ }
102
+
103
+ describe('createEmptyTree', () => {
104
+ it('returns an empty tree', () => {
105
+ const tree = createEmptyTree();
106
+ expect(tree.nodes).toEqual({});
107
+ expect(tree.rootId).toBeNull();
108
+ });
109
+ });
110
+
111
+ describe('getLinearPath', () => {
112
+ it('returns an empty array for an empty tree', () => {
113
+ expect(getLinearPath(createEmptyTree())).toEqual([]);
114
+ });
115
+
116
+ it('returns the active path from root following activeChildIndex', () => {
117
+ const tree = makeBranchingTree();
118
+ const path = getLinearPath(tree);
119
+
120
+ expect(path.map((m) => m.id)).toEqual(['u1', 'a1', 'u2', 'a2']);
121
+ });
122
+
123
+ it('returns a different path after changing activeChildIndex', () => {
124
+ const tree = makeBranchingTree();
125
+ tree.nodes.u1.activeChildIndex = 1;
126
+ const path = getLinearPath(tree);
127
+
128
+ expect(path.map((m) => m.id)).toEqual(['u1', 'a1b', 'u3']);
129
+ });
130
+
131
+ it('enriches messages with siblingIds and siblingIndex when siblings exist', () => {
132
+ const tree = makeBranchingTree();
133
+ const path = getLinearPath(tree);
134
+
135
+ const a1Msg = path.find((m) => m.id === 'a1')!;
136
+ expect(a1Msg.siblingIds).toEqual(['a1', 'a1b']);
137
+ expect(a1Msg.siblingIndex).toBe(0);
138
+
139
+ const a2Msg = path.find((m) => m.id === 'a2')!;
140
+ expect(a2Msg.siblingIds).toEqual(['a2', 'a3']);
141
+ expect(a2Msg.siblingIndex).toBe(0);
142
+ });
143
+
144
+ it('does not set siblingIds for nodes without siblings', () => {
145
+ const tree = makeBranchingTree();
146
+ const path = getLinearPath(tree);
147
+
148
+ const u2Msg = path.find((m) => m.id === 'u2')!;
149
+ expect(u2Msg.siblingIds).toBeUndefined();
150
+ expect(u2Msg.siblingIndex).toBeUndefined();
151
+ });
152
+
153
+ it('does not set siblingIds on the root node', () => {
154
+ const tree = makeBranchingTree();
155
+ const path = getLinearPath(tree);
156
+
157
+ const rootMsg = path[0];
158
+ expect(rootMsg.id).toBe('u1');
159
+ expect(rootMsg.siblingIds).toBeUndefined();
160
+ expect(rootMsg.siblingIndex).toBeUndefined();
161
+ });
162
+
163
+ it('preserves message fields', () => {
164
+ const tree = makeBranchingTree();
165
+ const path = getLinearPath(tree);
166
+
167
+ const u1Msg = path[0];
168
+ expect(u1Msg.role).toBe('user');
169
+ expect(u1Msg.content).toBe('Hello');
170
+ expect(u1Msg.status).toBe('completed');
171
+ expect(u1Msg.createdAt).toEqual(new Date('2025-01-01'));
172
+ });
173
+ });
174
+
175
+ describe('addNode', () => {
176
+ it('adds the first node as root', () => {
177
+ const tree = createEmptyTree();
178
+ const node = makeTreeMessage({ id: 'u1', content: 'Hello' });
179
+
180
+ addNode(tree, node);
181
+
182
+ expect(tree.nodes.u1).toBe(node);
183
+ expect(tree.rootId).toBe('u1');
184
+ });
185
+
186
+ it('appends a child and updates parent.childrenIds', () => {
187
+ const tree = createEmptyTree();
188
+ addNode(tree, makeTreeMessage({ id: 'u1' }));
189
+ addNode(
190
+ tree,
191
+ makeTreeMessage({ id: 'a1', parentId: 'u1', role: 'assistant' }),
192
+ );
193
+
194
+ expect(tree.nodes.u1.childrenIds).toEqual(['a1']);
195
+ });
196
+
197
+ it('sets parent.activeChildIndex to point at the new child', () => {
198
+ const tree = createEmptyTree();
199
+ addNode(tree, makeTreeMessage({ id: 'u1' }));
200
+ addNode(
201
+ tree,
202
+ makeTreeMessage({ id: 'a1', parentId: 'u1', role: 'assistant' }),
203
+ );
204
+
205
+ expect(tree.nodes.u1.activeChildIndex).toBe(0);
206
+
207
+ addNode(
208
+ tree,
209
+ makeTreeMessage({ id: 'a2', parentId: 'u1', role: 'assistant' }),
210
+ );
211
+
212
+ expect(tree.nodes.u1.activeChildIndex).toBe(1);
213
+ });
214
+
215
+ it('makes the new node the last in the active path', () => {
216
+ const tree = createEmptyTree();
217
+ addNode(tree, makeTreeMessage({ id: 'u1' }));
218
+ addNode(
219
+ tree,
220
+ makeTreeMessage({ id: 'a1', parentId: 'u1', role: 'assistant' }),
221
+ );
222
+ addNode(tree, makeTreeMessage({ id: 'u2', parentId: 'a1' }));
223
+
224
+ const path = getLinearPath(tree);
225
+ expect(path[path.length - 1].id).toBe('u2');
226
+ });
227
+
228
+ it('supports adding sibling nodes (regeneration)', () => {
229
+ const tree = createEmptyTree();
230
+ addNode(tree, makeTreeMessage({ id: 'u1' }));
231
+ addNode(
232
+ tree,
233
+ makeTreeMessage({ id: 'a1', parentId: 'u1', role: 'assistant' }),
234
+ );
235
+ addNode(
236
+ tree,
237
+ makeTreeMessage({ id: 'a2', parentId: 'u1', role: 'assistant' }),
238
+ );
239
+
240
+ expect(tree.nodes.u1.childrenIds).toEqual(['a1', 'a2']);
241
+ expect(tree.nodes.u1.activeChildIndex).toBe(1);
242
+
243
+ const path = getLinearPath(tree);
244
+ expect(path.map((m) => m.id)).toEqual(['u1', 'a2']);
245
+ });
246
+ });
247
+
248
+ describe('updateNode', () => {
249
+ it('updates content of an existing node', () => {
250
+ const tree = makeBranchingTree();
251
+ updateNode(tree, 'a1', { content: 'Updated hi' });
252
+ expect(tree.nodes.a1.content).toBe('Updated hi');
253
+ });
254
+
255
+ it('updates status of an existing node', () => {
256
+ const tree = makeBranchingTree();
257
+ updateNode(tree, 'a1', { status: 'sending' });
258
+ expect(tree.nodes.a1.status).toBe('sending');
259
+ });
260
+
261
+ it('is a no-op for nonexistent id', () => {
262
+ const tree = makeBranchingTree();
263
+ const nodeCount = Object.keys(tree.nodes).length;
264
+ updateNode(tree, 'nonexistent', { content: 'x' });
265
+ expect(Object.keys(tree.nodes).length).toBe(nodeCount);
266
+ });
267
+ });
268
+
269
+ describe('updateNodeId', () => {
270
+ it('re-keys a node from old to new id', () => {
271
+ const tree = makeBranchingTree();
272
+ updateNodeId(tree, 'a2', 'a2-server');
273
+
274
+ expect(tree.nodes['a2-server']).toBeDefined();
275
+ expect(tree.nodes['a2']).toBeUndefined();
276
+ expect(tree.nodes['a2-server'].id).toBe('a2-server');
277
+ });
278
+
279
+ it('updates parent.childrenIds reference', () => {
280
+ const tree = makeBranchingTree();
281
+ updateNodeId(tree, 'a2', 'a2-server');
282
+
283
+ expect(tree.nodes.u2.childrenIds).toContain('a2-server');
284
+ expect(tree.nodes.u2.childrenIds).not.toContain('a2');
285
+ });
286
+
287
+ it('updates children.parentId references', () => {
288
+ const tree = makeBranchingTree();
289
+ updateNodeId(tree, 'u2', 'u2-server');
290
+
291
+ expect(tree.nodes.a2.parentId).toBe('u2-server');
292
+ expect(tree.nodes.a3.parentId).toBe('u2-server');
293
+ });
294
+
295
+ it('updates rootId when renaming the root', () => {
296
+ const tree = makeBranchingTree();
297
+ updateNodeId(tree, 'u1', 'u1-server');
298
+ expect(tree.rootId).toBe('u1-server');
299
+ });
300
+
301
+ it('is a no-op for nonexistent oldId', () => {
302
+ const tree = makeBranchingTree();
303
+ updateNodeId(tree, 'nonexistent', 'whatever');
304
+ expect(tree.nodes['whatever']).toBeUndefined();
305
+ });
306
+ });
307
+
308
+ describe('navigate', () => {
309
+ it('updates parent.activeChildIndex to the target sibling', () => {
310
+ const tree = makeBranchingTree();
311
+ expect(tree.nodes.u1.activeChildIndex).toBe(0);
312
+
313
+ navigate(tree, 'a1b');
314
+
315
+ expect(tree.nodes.u1.activeChildIndex).toBe(1);
316
+ });
317
+
318
+ it('changes the linear path accordingly', () => {
319
+ const tree = makeBranchingTree();
320
+
321
+ navigate(tree, 'a1b');
322
+
323
+ const path = getLinearPath(tree);
324
+ expect(path.map((m) => m.id)).toEqual(['u1', 'a1b', 'u3']);
325
+ });
326
+
327
+ it('navigating back restores the original path', () => {
328
+ const tree = makeBranchingTree();
329
+
330
+ navigate(tree, 'a1b');
331
+ navigate(tree, 'a1');
332
+
333
+ const path = getLinearPath(tree);
334
+ expect(path.map((m) => m.id)).toEqual(['u1', 'a1', 'u2', 'a2']);
335
+ });
336
+
337
+ it('navigates between leaf siblings', () => {
338
+ const tree = makeBranchingTree();
339
+
340
+ navigate(tree, 'a3');
341
+ expect(tree.nodes.u2.activeChildIndex).toBe(1);
342
+
343
+ const path = getLinearPath(tree);
344
+ expect(path.map((m) => m.id)).toEqual(['u1', 'a1', 'u2', 'a3']);
345
+ });
346
+
347
+ it('is a no-op for nonexistent targetId', () => {
348
+ const tree = makeBranchingTree();
349
+ const originalIndex = tree.nodes.u1.activeChildIndex;
350
+
351
+ navigate(tree, 'nonexistent');
352
+
353
+ expect(tree.nodes.u1.activeChildIndex).toBe(originalIndex);
354
+ });
355
+
356
+ it('is a no-op for root node (has no parent)', () => {
357
+ const tree = makeBranchingTree();
358
+
359
+ navigate(tree, 'u1');
360
+
361
+ expect(tree.nodes.u1.activeChildIndex).toBe(0);
362
+ });
363
+ });
364
+
365
+ describe('integration: full branching scenario', () => {
366
+ it('simulates send → regenerate → navigate workflow', () => {
367
+ const tree = createEmptyTree();
368
+
369
+ addNode(tree, makeTreeMessage({ id: 'u1', content: 'Hello' }));
370
+ addNode(
371
+ tree,
372
+ makeTreeMessage({
373
+ id: 'a1',
374
+ parentId: 'u1',
375
+ role: 'assistant',
376
+ content: 'Hi there',
377
+ }),
378
+ );
379
+ addNode(
380
+ tree,
381
+ makeTreeMessage({ id: 'u2', parentId: 'a1', content: 'Tell me more' }),
382
+ );
383
+ addNode(
384
+ tree,
385
+ makeTreeMessage({
386
+ id: 'a2',
387
+ parentId: 'u2',
388
+ role: 'assistant',
389
+ content: 'Sure thing',
390
+ }),
391
+ );
392
+
393
+ let path = getLinearPath(tree);
394
+ expect(path.map((m) => m.id)).toEqual(['u1', 'a1', 'u2', 'a2']);
395
+
396
+ addNode(
397
+ tree,
398
+ makeTreeMessage({
399
+ id: 'a3',
400
+ parentId: 'u2',
401
+ role: 'assistant',
402
+ content: 'Certainly!',
403
+ }),
404
+ );
405
+
406
+ path = getLinearPath(tree);
407
+ expect(path.map((m) => m.id)).toEqual(['u1', 'a1', 'u2', 'a3']);
408
+
409
+ navigate(tree, 'a2');
410
+ path = getLinearPath(tree);
411
+ expect(path.map((m) => m.id)).toEqual(['u1', 'a1', 'u2', 'a2']);
412
+
413
+ const a2Msg = path.find((m) => m.id === 'a2')!;
414
+ expect(a2Msg.siblingIds).toEqual(['a2', 'a3']);
415
+ expect(a2Msg.siblingIndex).toBe(0);
416
+
417
+ navigate(tree, 'a3');
418
+ path = getLinearPath(tree);
419
+ const a3Msg = path.find((m) => m.id === 'a3')!;
420
+ expect(a3Msg.siblingIds).toEqual(['a2', 'a3']);
421
+ expect(a3Msg.siblingIndex).toBe(1);
422
+ });
423
+ });
@@ -0,0 +1,117 @@
1
+ import type { BaseMessage, ConversationTree, TreeMessage } from '../types';
2
+
3
+ export function createEmptyTree(): ConversationTree {
4
+ return { nodes: {}, rootId: null };
5
+ }
6
+
7
+ /**
8
+ * Walks from rootId following activeChildIndex at each node.
9
+ * Enriches each node with sibling metadata for the UI.
10
+ */
11
+ export function getLinearPath(tree: ConversationTree): BaseMessage[] {
12
+ const { nodes, rootId } = tree;
13
+ if (!rootId || !nodes[rootId]) return [];
14
+
15
+ const path: BaseMessage[] = [];
16
+ let current: string | null = rootId;
17
+
18
+ while (current !== null) {
19
+ const node: TreeMessage | undefined = nodes[current];
20
+ if (!node) break;
21
+
22
+ const base: BaseMessage = {
23
+ id: node.id,
24
+ role: node.role,
25
+ content: node.content,
26
+ createdAt: node.createdAt,
27
+ status: node.status,
28
+ };
29
+
30
+ if (node.parentId !== null) {
31
+ const parent = nodes[node.parentId];
32
+ if (parent && parent.childrenIds.length > 1) {
33
+ base.siblingIds = [...parent.childrenIds];
34
+ base.siblingIndex = parent.childrenIds.indexOf(node.id);
35
+ }
36
+ }
37
+
38
+ path.push(base);
39
+
40
+ if (node.childrenIds.length === 0) break;
41
+ current = node.childrenIds[node.activeChildIndex];
42
+ }
43
+
44
+ return path;
45
+ }
46
+
47
+ export function addNode(tree: ConversationTree, node: TreeMessage): void {
48
+ tree.nodes[node.id] = node;
49
+
50
+ if (node.parentId !== null) {
51
+ const parent = tree.nodes[node.parentId];
52
+ if (parent && !parent.childrenIds.includes(node.id)) {
53
+ parent.childrenIds.push(node.id);
54
+ parent.activeChildIndex = parent.childrenIds.length - 1;
55
+ }
56
+ } else if (tree.rootId === null) {
57
+ tree.rootId = node.id;
58
+ }
59
+ }
60
+
61
+ export function updateNode(
62
+ tree: ConversationTree,
63
+ id: string,
64
+ updates: Partial<Pick<TreeMessage, 'content' | 'status' | 'role'>>,
65
+ ): void {
66
+ const node = tree.nodes[id];
67
+ if (!node) return;
68
+
69
+ if (updates.content !== undefined) node.content = updates.content;
70
+ if (updates.status !== undefined) node.status = updates.status;
71
+ if (updates.role !== undefined) node.role = updates.role;
72
+ }
73
+
74
+ export function updateNodeId(
75
+ tree: ConversationTree,
76
+ oldId: string,
77
+ newId: string,
78
+ ): void {
79
+ const node = tree.nodes[oldId];
80
+ if (!node) return;
81
+
82
+ delete tree.nodes[oldId];
83
+ node.id = newId;
84
+ tree.nodes[newId] = node;
85
+
86
+ if (node.parentId !== null) {
87
+ const parent = tree.nodes[node.parentId];
88
+ if (parent) {
89
+ const idx = parent.childrenIds.indexOf(oldId);
90
+ if (idx !== -1) parent.childrenIds[idx] = newId;
91
+ }
92
+ }
93
+
94
+ for (const childId of node.childrenIds) {
95
+ const child = tree.nodes[childId];
96
+ if (child) child.parentId = newId;
97
+ }
98
+
99
+ if (tree.rootId === oldId) tree.rootId = newId;
100
+ }
101
+
102
+ /**
103
+ * Navigate to a different branch by updating the parent's activeChildIndex
104
+ * to point at the target sibling.
105
+ */
106
+ export function navigate(tree: ConversationTree, targetId: string): void {
107
+ const node = tree.nodes[targetId];
108
+ if (!node || node.parentId === null) return;
109
+
110
+ const parent = tree.nodes[node.parentId];
111
+ if (!parent) return;
112
+
113
+ const idx = parent.childrenIds.indexOf(targetId);
114
+ if (idx === -1) return;
115
+
116
+ parent.activeChildIndex = idx;
117
+ }
@@ -0,0 +1,5 @@
1
+ export function generateId() {
2
+ return typeof crypto !== 'undefined' && 'randomUUID' in crypto
3
+ ? crypto.randomUUID()
4
+ : String(Date.now());
5
+ }
@@ -0,0 +1,4 @@
1
+ export * from './conversationTree';
2
+ export * from './generateId';
3
+ export * from './secondsToDate';
4
+ export * from './toError';
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Convert a unix timestamp in seconds to a `Date`. Useful when consuming
3
+ * APIs that return `created_at` / `updated_at` as integer seconds.
4
+ */
5
+ export function secondsToDate(seconds: number): Date {
6
+ return new Date(seconds * 1000);
7
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Normalise an unknown thrown value into an `Error` instance.
3
+ * Wraps non-Error values (strings, plain objects, etc.) so callers can
4
+ * always rely on `.message` and a stable type.
5
+ */
6
+ export function toError(value: unknown): Error {
7
+ return value instanceof Error ? value : new Error(String(value));
8
+ }