@aleph-alpha/chat-kit 1.0.0 → 1.2.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 (133) hide show
  1. package/dist/{CkInput.vue_vue_type_script_setup_true_lang-744ZHsQU.js → CkInput.vue_vue_type_script_setup_true_lang-CRymGkQs.js} +24 -69
  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/composables/index.d.ts +2 -0
  9. package/dist/composables/index.d.ts.map +1 -1
  10. package/dist/composables/index.js +3 -2
  11. package/dist/composables/useConversationSessions.d.ts +29 -0
  12. package/dist/composables/useConversationSessions.d.ts.map +1 -0
  13. package/dist/handlers/index.js +2 -2
  14. package/dist/index.d.ts +2 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +7 -6
  17. package/dist/purify.es-md74IFlY.js +1081 -0
  18. package/dist/{useChatKitLabels-Dvi2FmjE.js → useChatKitLabels-BmWmMyqX.js} +75 -1152
  19. package/dist/{useConversations-Ca242D2P.js → useConversationSessions-ulFhtpQr.js} +50 -2
  20. package/dist/{useMessageHandler-uLHNac39.js → useMessageHandler-BmBdu2EW.js} +4 -1
  21. package/package.json +5 -5
  22. package/src/components/base/CkAgentMessage/CkAgentMessage.spec.ts +91 -0
  23. package/src/components/base/CkAgentMessage/CkAgentMessage.stories.ts +215 -0
  24. package/src/components/base/CkAgentMessage/CkAgentMessage.vue +25 -0
  25. package/src/components/base/CkAgentMessage/index.ts +2 -0
  26. package/src/components/base/CkAgentMessage/types.ts +11 -0
  27. package/src/components/base/CkConversationLayout/CkConversationLayout.spec.ts +129 -0
  28. package/src/components/base/CkConversationLayout/CkConversationLayout.stories.ts +251 -0
  29. package/src/components/base/CkConversationLayout/CkConversationLayout.vue +206 -0
  30. package/src/components/base/CkConversationLayout/index.ts +2 -0
  31. package/src/components/base/CkConversationLayout/types.ts +18 -0
  32. package/src/components/base/CkCopyMessageActionButton/CkCopyMessageActionButton.spec.ts +89 -0
  33. package/src/components/base/CkCopyMessageActionButton/CkCopyMessageActionButton.stories.ts +70 -0
  34. package/src/components/base/CkCopyMessageActionButton/CkCopyMessageActionButton.vue +110 -0
  35. package/src/components/base/CkCopyMessageActionButton/index.ts +2 -0
  36. package/src/components/base/CkCopyMessageActionButton/types.ts +43 -0
  37. package/src/components/base/CkHistoryItem/CkHistoryItem.spec.ts +42 -0
  38. package/src/components/base/CkHistoryItem/CkHistoryItem.stories.ts +230 -0
  39. package/src/components/base/CkHistoryItem/CkHistoryItem.vue +285 -0
  40. package/src/components/base/CkHistoryItem/activeIndicator.ts +15 -0
  41. package/src/components/base/CkHistoryItem/index.ts +2 -0
  42. package/src/components/base/CkHistoryItem/types.ts +111 -0
  43. package/src/components/base/CkHistoryWrapper/CkHistoryWrapper.spec.ts +26 -0
  44. package/src/components/base/CkHistoryWrapper/CkHistoryWrapper.stories.ts +149 -0
  45. package/src/components/base/CkHistoryWrapper/CkHistoryWrapper.vue +36 -0
  46. package/src/components/base/CkHistoryWrapper/index.ts +2 -0
  47. package/src/components/base/CkHistoryWrapper/types.ts +16 -0
  48. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.spec.ts +348 -0
  49. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.ts +382 -0
  50. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue +89 -0
  51. package/src/components/base/CkMarkdownRenderer/defaultMarkdownClasses.ts +19 -0
  52. package/src/components/base/CkMarkdownRenderer/index.ts +5 -0
  53. package/src/components/base/CkMarkdownRenderer/types.ts +53 -0
  54. package/src/components/base/CkMessageActionButton/CkMessageActionButton.spec.ts +40 -0
  55. package/src/components/base/CkMessageActionButton/CkMessageActionButton.stories.ts +56 -0
  56. package/src/components/base/CkMessageActionButton/CkMessageActionButton.vue +23 -0
  57. package/src/components/base/CkMessageActionButton/index.ts +2 -0
  58. package/src/components/base/CkMessageActionButton/types.ts +21 -0
  59. package/src/components/base/CkPromptActionButton/CkPromptActionButton.spec.ts +52 -0
  60. package/src/components/base/CkPromptActionButton/CkPromptActionButton.stories.ts +126 -0
  61. package/src/components/base/CkPromptActionButton/CkPromptActionButton.vue +25 -0
  62. package/src/components/base/CkPromptActionButton/index.ts +2 -0
  63. package/src/components/base/CkPromptActionButton/types.ts +32 -0
  64. package/src/components/base/CkPromptInput/CkPromptInput.spec.ts +181 -0
  65. package/src/components/base/CkPromptInput/CkPromptInput.stories.ts +224 -0
  66. package/src/components/base/CkPromptInput/CkPromptInput.vue +104 -0
  67. package/src/components/base/CkPromptInput/index.ts +2 -0
  68. package/src/components/base/CkPromptInput/types.ts +39 -0
  69. package/src/components/base/CkPromptInputDisclaimer/CkPromptInputDisclaimer.spec.ts +40 -0
  70. package/src/components/base/CkPromptInputDisclaimer/CkPromptInputDisclaimer.stories.ts +103 -0
  71. package/src/components/base/CkPromptInputDisclaimer/CkPromptInputDisclaimer.vue +16 -0
  72. package/src/components/base/CkPromptInputDisclaimer/index.ts +2 -0
  73. package/src/components/base/CkPromptInputDisclaimer/types.ts +17 -0
  74. package/src/components/base/CkPromptStopButton/CkPromptStopButton.spec.ts +64 -0
  75. package/src/components/base/CkPromptStopButton/CkPromptStopButton.stories.ts +81 -0
  76. package/src/components/base/CkPromptStopButton/CkPromptStopButton.vue +27 -0
  77. package/src/components/base/CkPromptStopButton/index.ts +2 -0
  78. package/src/components/base/CkPromptStopButton/types.ts +28 -0
  79. package/src/components/base/CkUserMessage/CkUserMessage.spec.ts +20 -0
  80. package/src/components/base/CkUserMessage/CkUserMessage.stories.ts +112 -0
  81. package/src/components/base/CkUserMessage/CkUserMessage.vue +19 -0
  82. package/src/components/base/CkUserMessage/index.ts +2 -0
  83. package/src/components/base/CkUserMessage/types.ts +14 -0
  84. package/src/components/base/icons/CkSendIcon.vue +22 -0
  85. package/src/components/base/icons/CkStopIcon.vue +18 -0
  86. package/src/components/base/icons/index.ts +2 -0
  87. package/src/components/base/index.ts +21 -0
  88. package/src/components/composed/CkConversation/CkConversation.spec.ts +439 -0
  89. package/src/components/composed/CkConversation/CkConversation.stories.ts +642 -0
  90. package/src/components/composed/CkConversation/CkConversation.vue +146 -0
  91. package/src/components/composed/CkConversation/index.ts +2 -0
  92. package/src/components/composed/CkConversation/types.ts +37 -0
  93. package/src/components/composed/CkHistory/CkHistory.spec.ts +327 -0
  94. package/src/components/composed/CkHistory/CkHistory.stories.ts +373 -0
  95. package/src/components/composed/CkHistory/CkHistory.vue +126 -0
  96. package/src/components/composed/CkHistory/index.ts +2 -0
  97. package/src/components/composed/CkHistory/types.ts +72 -0
  98. package/src/components/composed/CkInput/CkInput.spec.ts +191 -0
  99. package/src/components/composed/CkInput/CkInput.stories.ts +438 -0
  100. package/src/components/composed/CkInput/CkInput.vue +104 -0
  101. package/src/components/composed/CkInput/index.ts +2 -0
  102. package/src/components/composed/CkInput/types.ts +60 -0
  103. package/src/components/composed/index.ts +5 -0
  104. package/src/components/index.ts +34 -0
  105. package/src/composables/index.ts +15 -0
  106. package/src/composables/types.ts +36 -0
  107. package/src/composables/useChat.ts +18 -0
  108. package/src/composables/useConversationSessions.spec.ts +86 -0
  109. package/src/composables/useConversationSessions.ts +85 -0
  110. package/src/composables/useConversations.spec.ts +557 -0
  111. package/src/composables/useConversations.ts +321 -0
  112. package/src/handlers/index.ts +9 -0
  113. package/src/handlers/useChatKitLabels.spec.ts +290 -0
  114. package/src/handlers/useChatKitLabels.ts +96 -0
  115. package/src/handlers/useMarkdownParser.spec.ts +112 -0
  116. package/src/handlers/useMarkdownParser.ts +47 -0
  117. package/src/handlers/useMessageHandler.spec.ts +535 -0
  118. package/src/handlers/useMessageHandler.ts +138 -0
  119. package/src/helpers/conversationTree.spec.ts +423 -0
  120. package/src/helpers/conversationTree.ts +117 -0
  121. package/src/helpers/generateId.ts +5 -0
  122. package/src/helpers/index.ts +4 -0
  123. package/src/helpers/secondsToDate.ts +7 -0
  124. package/src/helpers/toError.ts +8 -0
  125. package/src/index.ts +86 -0
  126. package/src/services/ChatService.ts +60 -0
  127. package/src/services/MockChatService.ts +47 -0
  128. package/src/services/index.ts +12 -0
  129. package/src/services/types.ts +92 -0
  130. package/src/test-setup.ts +12 -0
  131. package/src/test-utils/createTextPlugin.ts +9 -0
  132. package/src/types.ts +47 -0
  133. package/dist/generateId-BHf0W4Rq.js +0 -6
@@ -0,0 +1,112 @@
1
+ import { nextTick, ref } from 'vue';
2
+ import { useMarkdownParser } from './useMarkdownParser';
3
+ import { createTextPlugin } from '../test-utils/createTextPlugin';
4
+
5
+ describe('useMarkdownParser', () => {
6
+ it('converts a plain text paragraph to HTML', () => {
7
+ const source = ref('Hello world');
8
+ const { html } = useMarkdownParser(source, ref(undefined));
9
+
10
+ expect(html.value).toBe('<p>Hello world</p>');
11
+ });
12
+
13
+ it('converts headings and inline formatting', () => {
14
+ const source = ref('# Title\n\n**bold** and *italic*');
15
+ const { html } = useMarkdownParser(source, ref(undefined));
16
+
17
+ expect(html.value).toContain('<h1>Title</h1>');
18
+ expect(html.value).toContain('<strong>bold</strong>');
19
+ expect(html.value).toContain('<em>italic</em>');
20
+ });
21
+
22
+ it('re-computes when the source ref changes', async () => {
23
+ const source = ref('first');
24
+ const { html } = useMarkdownParser(source, ref(undefined));
25
+
26
+ expect(html.value).toBe('<p>first</p>');
27
+
28
+ source.value = 'second';
29
+ await nextTick();
30
+
31
+ expect(html.value).toBe('<p>second</p>');
32
+ });
33
+
34
+ it('returns empty string for empty source', () => {
35
+ const source = ref('');
36
+ const { html } = useMarkdownParser(source, ref(undefined));
37
+
38
+ expect(html.value).toBe('');
39
+ });
40
+
41
+ it('renders GFM tables', () => {
42
+ const source = ref('| A | B |\n| --- | --- |\n| 1 | 2 |');
43
+ const { html } = useMarkdownParser(source, ref(undefined));
44
+
45
+ expect(html.value).toContain('<table>');
46
+ expect(html.value).toContain('<th>A</th>');
47
+ expect(html.value).toContain('<td>1</td>');
48
+ });
49
+
50
+ it('renders GFM strikethrough', () => {
51
+ const source = ref('~~deleted~~');
52
+ const { html } = useMarkdownParser(source, ref(undefined));
53
+
54
+ expect(html.value).toContain('<del>deleted</del>');
55
+ });
56
+
57
+ it('renders GFM task list checkboxes', () => {
58
+ const source = ref('- [x] done\n- [ ] pending');
59
+ const { html } = useMarkdownParser(source, ref(undefined));
60
+
61
+ expect(html.value).toContain('checked=""');
62
+ expect(html.value).toContain('type="checkbox"');
63
+ });
64
+
65
+ it('auto-links bare URLs', () => {
66
+ const source = ref('Visit https://example.com for details');
67
+ const { html } = useMarkdownParser(source, ref(undefined));
68
+
69
+ expect(html.value).toContain('<a href="https://example.com">');
70
+ });
71
+
72
+ it('sanitizes script tags', () => {
73
+ const source = ref('Safe\n\n<script>alert("xss")</script>');
74
+ const { html } = useMarkdownParser(source, ref(undefined));
75
+
76
+ expect(html.value).not.toContain('<script>');
77
+ expect(html.value).toContain('Safe');
78
+ });
79
+
80
+ it('strips onerror attributes from images', () => {
81
+ const source = ref('<img src=x onerror=alert(1)>');
82
+ const { html } = useMarkdownParser(source, ref(undefined));
83
+
84
+ expect(html.value).not.toContain('onerror');
85
+ });
86
+
87
+ it('applies a custom plugin', () => {
88
+ const source = ref('hello world');
89
+ const plugins = ref([createTextPlugin((t) => t.toUpperCase())]);
90
+ const { html } = useMarkdownParser(source, plugins);
91
+
92
+ expect(html.value).toBe('<p>HELLO WORLD</p>');
93
+ });
94
+
95
+ it('applies multiple plugins in order', () => {
96
+ const source = ref('hello');
97
+ const plugins = ref([
98
+ createTextPlugin((t) => `prefix:${t}`),
99
+ createTextPlugin((t) => t.toUpperCase()),
100
+ ]);
101
+ const { html } = useMarkdownParser(source, plugins);
102
+
103
+ expect(html.value).toBe('<p>PREFIX:HELLO</p>');
104
+ });
105
+
106
+ it('works when plugins is undefined', () => {
107
+ const source = ref('Hello world');
108
+ const { html } = useMarkdownParser(source, ref(undefined));
109
+
110
+ expect(html.value).toBe('<p>Hello world</p>');
111
+ });
112
+ });
@@ -0,0 +1,47 @@
1
+ import { computed, type MaybeRefOrGetter, toValue } from 'vue';
2
+ import { unified, type PluggableList } from 'unified';
3
+ import remarkParse from 'remark-parse';
4
+ import remarkGfm from 'remark-gfm';
5
+ import remarkRehype from 'remark-rehype';
6
+ import rehypeStringify from 'rehype-stringify';
7
+ import DOMPurify from 'dompurify';
8
+
9
+ type SyncProcessor = { processSync: (value: string) => { toString(): string } };
10
+
11
+ function buildProcessor(plugins: PluggableList): SyncProcessor {
12
+ let proc = unified().use(remarkParse).use(remarkGfm);
13
+ for (const plugin of plugins) {
14
+ proc = proc.use(plugin as any);
15
+ }
16
+ return proc.use(remarkRehype).use(rehypeStringify);
17
+ }
18
+
19
+ export function useMarkdownParser(
20
+ source: MaybeRefOrGetter<string>,
21
+ plugins: MaybeRefOrGetter<PluggableList | undefined>,
22
+ ) {
23
+ let cachedPlugins: PluggableList | undefined;
24
+ let cachedProcessor: SyncProcessor | undefined;
25
+
26
+ function getProcessor(list: PluggableList): SyncProcessor {
27
+ if (list === cachedPlugins) return cachedProcessor!;
28
+ cachedPlugins = list;
29
+ cachedProcessor = buildProcessor(list);
30
+ return cachedProcessor;
31
+ }
32
+
33
+ const html = computed(() => {
34
+ const markdown = toValue(source);
35
+ if (!markdown) return '';
36
+ try {
37
+ const raw = String(
38
+ getProcessor(toValue(plugins) ?? []).processSync(markdown),
39
+ );
40
+ return DOMPurify.sanitize(raw);
41
+ } catch {
42
+ return DOMPurify.sanitize(markdown);
43
+ }
44
+ });
45
+
46
+ return { html };
47
+ }
@@ -0,0 +1,535 @@
1
+ import type { ConversationTree, TreeMessage } from '../types';
2
+ import { useMessageHandler } from './useMessageHandler';
3
+
4
+ function makeTreeMessage(
5
+ overrides: Partial<TreeMessage> & { id: string },
6
+ ): TreeMessage {
7
+ return {
8
+ parentId: null,
9
+ childrenIds: [],
10
+ activeChildIndex: 0,
11
+ role: 'user',
12
+ content: '',
13
+ status: 'completed',
14
+ createdAt: new Date('2025-01-01'),
15
+ ...overrides,
16
+ };
17
+ }
18
+
19
+ function makeTree(
20
+ nodes: Record<string, TreeMessage>,
21
+ rootId: string,
22
+ ): ConversationTree {
23
+ for (const node of Object.values(nodes)) {
24
+ node.childrenIds = [];
25
+ }
26
+ for (const node of Object.values(nodes)) {
27
+ if (node.parentId !== null) {
28
+ const parent = nodes[node.parentId];
29
+ if (parent) parent.childrenIds.push(node.id);
30
+ }
31
+ }
32
+ for (const node of Object.values(nodes)) {
33
+ node.activeChildIndex = 0;
34
+ }
35
+ return { nodes, rootId };
36
+ }
37
+
38
+ describe('useMessageHandler', () => {
39
+ describe('commitUserMessage', () => {
40
+ it('adds a user message to the map and exposes it in the ordered list', () => {
41
+ const handler = useMessageHandler();
42
+ handler.setCurrentUserMessage('Hello');
43
+ const msg = handler.commitUserMessage();
44
+
45
+ expect(msg.role).toBe('user');
46
+ expect(msg.content).toBe('Hello');
47
+ expect(handler.messages.value).toHaveLength(1);
48
+ expect(handler.messages.value[0].id).toBe(msg.id);
49
+ });
50
+
51
+ it('appends consecutive messages in order', () => {
52
+ const handler = useMessageHandler();
53
+
54
+ handler.setCurrentUserMessage('First');
55
+ const first = handler.commitUserMessage();
56
+
57
+ handler.setCurrentUserMessage('Second');
58
+ const second = handler.commitUserMessage();
59
+
60
+ expect(handler.messages.value).toHaveLength(2);
61
+ expect(handler.messages.value[0].id).toBe(first.id);
62
+ expect(handler.messages.value[1].id).toBe(second.id);
63
+ });
64
+
65
+ it('clears currentUserMessage after commit', () => {
66
+ const handler = useMessageHandler();
67
+ handler.setCurrentUserMessage('Hello');
68
+ handler.commitUserMessage();
69
+
70
+ expect(handler.currentUserMessage.value).toBe('');
71
+ });
72
+
73
+ it('throws when current message is empty', () => {
74
+ const handler = useMessageHandler();
75
+ expect(() => handler.commitUserMessage()).toThrow('No message');
76
+ });
77
+
78
+ it('throws when current message is only whitespace', () => {
79
+ const handler = useMessageHandler();
80
+ handler.setCurrentUserMessage(' ');
81
+ expect(() => handler.commitUserMessage()).toThrow('No message');
82
+ });
83
+ });
84
+
85
+ describe('updateMessage', () => {
86
+ it('updates an existing message in place', () => {
87
+ const handler = useMessageHandler();
88
+ handler.setCurrentUserMessage('Hello');
89
+ const msg = handler.commitUserMessage();
90
+
91
+ handler.updateMessage({
92
+ id: msg.id,
93
+ content: 'Updated',
94
+ role: 'user',
95
+ status: 'completed',
96
+ });
97
+
98
+ expect(handler.messages.value[0].content).toBe('Updated');
99
+ });
100
+
101
+ it('creates a new node when id does not exist (upsert)', () => {
102
+ const handler = useMessageHandler();
103
+ handler.updateMessage({
104
+ id: 'new-1',
105
+ content: 'Upserted',
106
+ role: 'assistant',
107
+ status: 'completed',
108
+ });
109
+
110
+ expect(handler.messages.value).toHaveLength(1);
111
+ expect(handler.messages.value[0].content).toBe('Upserted');
112
+ expect(handler.messages.value[0].id).toBe('new-1');
113
+ });
114
+
115
+ it('upserted message is appended after existing messages', () => {
116
+ const handler = useMessageHandler();
117
+ handler.setCurrentUserMessage('Hello');
118
+ handler.commitUserMessage();
119
+
120
+ handler.updateMessage({
121
+ id: 'assistant-1',
122
+ content: 'Reply',
123
+ role: 'assistant',
124
+ status: 'completed',
125
+ });
126
+
127
+ expect(handler.messages.value).toHaveLength(2);
128
+ expect(handler.messages.value[1].id).toBe('assistant-1');
129
+ expect(handler.messages.value[1].content).toBe('Reply');
130
+ });
131
+
132
+ it('updates a mid-tree node in place without truncating the path', () => {
133
+ const handler = useMessageHandler();
134
+ handler.setTree(
135
+ makeTree(
136
+ {
137
+ u1: makeTreeMessage({ id: 'u1', content: 'First' }),
138
+ a1: makeTreeMessage({
139
+ id: 'a1',
140
+ parentId: 'u1',
141
+ role: 'assistant',
142
+ content: 'Reply 1',
143
+ }),
144
+ u2: makeTreeMessage({
145
+ id: 'u2',
146
+ parentId: 'a1',
147
+ content: 'Second',
148
+ }),
149
+ a2: makeTreeMessage({
150
+ id: 'a2',
151
+ parentId: 'u2',
152
+ role: 'assistant',
153
+ content: 'Reply 2',
154
+ }),
155
+ },
156
+ 'u1',
157
+ ),
158
+ );
159
+
160
+ handler.updateMessage({
161
+ id: 'a1',
162
+ content: 'Regenerated',
163
+ role: 'assistant',
164
+ status: 'sending',
165
+ });
166
+
167
+ expect(handler.messages.value).toHaveLength(4);
168
+ expect(handler.messages.value[1].content).toBe('Regenerated');
169
+ });
170
+
171
+ it('does not truncate when updating the last message', () => {
172
+ const handler = useMessageHandler();
173
+ handler.setTree(
174
+ makeTree(
175
+ {
176
+ u1: makeTreeMessage({ id: 'u1', content: 'Hello' }),
177
+ a1: makeTreeMessage({
178
+ id: 'a1',
179
+ parentId: 'u1',
180
+ role: 'assistant',
181
+ content: 'Hi',
182
+ }),
183
+ },
184
+ 'u1',
185
+ ),
186
+ );
187
+
188
+ handler.updateMessage({
189
+ id: 'a1',
190
+ content: 'Streaming...',
191
+ role: 'assistant',
192
+ status: 'sending',
193
+ });
194
+
195
+ expect(handler.messages.value).toHaveLength(2);
196
+ expect(handler.messages.value[1].content).toBe('Streaming...');
197
+ });
198
+ });
199
+
200
+ describe('getMessageById', () => {
201
+ it('returns the message for a known id', () => {
202
+ const handler = useMessageHandler();
203
+ handler.setCurrentUserMessage('Hello');
204
+ const msg = handler.commitUserMessage();
205
+
206
+ const found = handler.getMessageById(msg.id);
207
+ expect(found).toBeDefined();
208
+ expect(found?.id).toBe(msg.id);
209
+ expect(found?.content).toBe('Hello');
210
+ expect(found?.role).toBe('user');
211
+ });
212
+
213
+ it('returns undefined for an unknown id', () => {
214
+ const handler = useMessageHandler();
215
+ expect(handler.getMessageById('nonexistent')).toBeUndefined();
216
+ });
217
+ });
218
+
219
+ describe('updateMessageId', () => {
220
+ it('renames the id of an existing message', () => {
221
+ const handler = useMessageHandler();
222
+ handler.setCurrentUserMessage('Hello');
223
+ const msg = handler.commitUserMessage();
224
+ const oldId = msg.id;
225
+
226
+ handler.updateMessageId(oldId, 'new-id');
227
+
228
+ expect(handler.messages.value[0].id).toBe('new-id');
229
+ expect(handler.getMessageById('new-id')).toBeDefined();
230
+ expect(handler.getMessageById(oldId)).toBeUndefined();
231
+ });
232
+
233
+ it('preserves all other message fields', () => {
234
+ const handler = useMessageHandler();
235
+ handler.setCurrentUserMessage('Hello');
236
+ const msg = handler.commitUserMessage();
237
+ const { role, content, status, createdAt } = msg;
238
+
239
+ handler.updateMessageId(msg.id, 'renamed');
240
+
241
+ const renamed = handler.getMessageById('renamed');
242
+ expect(renamed?.role).toBe(role);
243
+ expect(renamed?.content).toBe(content);
244
+ expect(renamed?.status).toBe(status);
245
+ expect(renamed?.createdAt).toEqual(createdAt);
246
+ });
247
+
248
+ it('keeps the message at the same position in the list', () => {
249
+ const handler = useMessageHandler();
250
+ handler.setTree(
251
+ makeTree(
252
+ {
253
+ a: makeTreeMessage({ id: 'a', content: 'A' }),
254
+ b: makeTreeMessage({
255
+ id: 'b',
256
+ parentId: 'a',
257
+ role: 'assistant',
258
+ content: 'B',
259
+ }),
260
+ c: makeTreeMessage({ id: 'c', parentId: 'b', content: 'C' }),
261
+ },
262
+ 'a',
263
+ ),
264
+ );
265
+
266
+ handler.updateMessageId('b', 'b-renamed');
267
+
268
+ expect(handler.messages.value.map((m) => m.id)).toEqual([
269
+ 'a',
270
+ 'b-renamed',
271
+ 'c',
272
+ ]);
273
+ });
274
+
275
+ it('is a no-op when oldId does not exist', () => {
276
+ const handler = useMessageHandler();
277
+ handler.setCurrentUserMessage('Hello');
278
+ handler.commitUserMessage();
279
+
280
+ handler.updateMessageId('nonexistent', 'whatever');
281
+
282
+ expect(handler.messages.value).toHaveLength(1);
283
+ expect(handler.getMessageById('whatever')).toBeUndefined();
284
+ });
285
+ });
286
+
287
+ describe('clearMessages', () => {
288
+ it('empties all state', () => {
289
+ const handler = useMessageHandler();
290
+ handler.setCurrentUserMessage('Hello');
291
+ handler.commitUserMessage();
292
+ handler.updateMessage({
293
+ id: 'a1',
294
+ content: 'Reply',
295
+ role: 'assistant',
296
+ status: 'completed',
297
+ });
298
+
299
+ handler.clearMessages();
300
+
301
+ expect(handler.messages.value).toHaveLength(0);
302
+ expect(handler.getMessageById('a1')).toBeUndefined();
303
+ });
304
+ });
305
+
306
+ describe('setCurrentUserMessage / clearCurrentUserMessage', () => {
307
+ it('sets and clears the current user message', () => {
308
+ const handler = useMessageHandler();
309
+
310
+ handler.setCurrentUserMessage('Draft');
311
+ expect(handler.currentUserMessage.value).toBe('Draft');
312
+
313
+ handler.clearCurrentUserMessage();
314
+ expect(handler.currentUserMessage.value).toBe('');
315
+ });
316
+ });
317
+
318
+ describe('messages computed', () => {
319
+ it('returns messages in root-to-leaf order', () => {
320
+ const handler = useMessageHandler();
321
+ handler.setCurrentUserMessage('One');
322
+ handler.commitUserMessage();
323
+
324
+ handler.updateMessage({
325
+ id: 'a1',
326
+ content: 'Reply 1',
327
+ role: 'assistant',
328
+ status: 'completed',
329
+ });
330
+
331
+ handler.setCurrentUserMessage('Two');
332
+ handler.commitUserMessage();
333
+
334
+ handler.updateMessage({
335
+ id: 'a2',
336
+ content: 'Reply 2',
337
+ role: 'assistant',
338
+ status: 'completed',
339
+ });
340
+
341
+ const roles = handler.messages.value.map((m) => m.role);
342
+ expect(roles).toEqual(['user', 'assistant', 'user', 'assistant']);
343
+
344
+ const contents = handler.messages.value.map((m) => m.content);
345
+ expect(contents).toEqual(['One', 'Reply 1', 'Two', 'Reply 2']);
346
+ });
347
+
348
+ it('updates reactively when a message is added', () => {
349
+ const handler = useMessageHandler();
350
+ expect(handler.messages.value).toHaveLength(0);
351
+
352
+ handler.setCurrentUserMessage('Hello');
353
+ handler.commitUserMessage();
354
+ expect(handler.messages.value).toHaveLength(1);
355
+
356
+ handler.updateMessage({
357
+ id: 'a1',
358
+ content: 'Hi',
359
+ role: 'assistant',
360
+ status: 'completed',
361
+ });
362
+ expect(handler.messages.value).toHaveLength(2);
363
+ });
364
+ });
365
+
366
+ describe('setTree', () => {
367
+ it('replaces the tree and exposes correct flat messages', () => {
368
+ const handler = useMessageHandler();
369
+ handler.setTree(
370
+ makeTree(
371
+ {
372
+ u1: makeTreeMessage({ id: 'u1', content: 'Hello' }),
373
+ a1: makeTreeMessage({
374
+ id: 'a1',
375
+ parentId: 'u1',
376
+ role: 'assistant',
377
+ content: 'Hi',
378
+ }),
379
+ },
380
+ 'u1',
381
+ ),
382
+ );
383
+
384
+ expect(handler.messages.value).toHaveLength(2);
385
+ expect(handler.messages.value.map((m) => m.id)).toEqual(['u1', 'a1']);
386
+ });
387
+
388
+ it('preserves branching from incoming tree', () => {
389
+ const handler = useMessageHandler();
390
+ handler.setTree(
391
+ makeTree(
392
+ {
393
+ r: makeTreeMessage({ id: 'r' }),
394
+ a: makeTreeMessage({
395
+ id: 'a',
396
+ parentId: 'r',
397
+ role: 'assistant',
398
+ content: 'A',
399
+ }),
400
+ b: makeTreeMessage({
401
+ id: 'b',
402
+ parentId: 'r',
403
+ role: 'assistant',
404
+ content: 'B',
405
+ }),
406
+ },
407
+ 'r',
408
+ ),
409
+ );
410
+
411
+ const first = handler.messages.value.find((m) => m.id === 'a')!;
412
+ expect(first.siblingIds).toEqual(['a', 'b']);
413
+ });
414
+ });
415
+
416
+ describe('navigate', () => {
417
+ it('switches the visible path to a different branch', () => {
418
+ const handler = useMessageHandler();
419
+ handler.setTree(
420
+ makeTree(
421
+ {
422
+ u1: makeTreeMessage({ id: 'u1', content: 'Hello' }),
423
+ a1: makeTreeMessage({
424
+ id: 'a1',
425
+ parentId: 'u1',
426
+ role: 'assistant',
427
+ content: 'Hi',
428
+ }),
429
+ a2: makeTreeMessage({
430
+ id: 'a2',
431
+ parentId: 'u1',
432
+ role: 'assistant',
433
+ content: 'Hey',
434
+ }),
435
+ u2: makeTreeMessage({
436
+ id: 'u2',
437
+ parentId: 'a2',
438
+ content: 'Follow-up',
439
+ }),
440
+ },
441
+ 'u1',
442
+ ),
443
+ );
444
+
445
+ expect(handler.messages.value.map((m) => m.id)).toEqual(['u1', 'a1']);
446
+
447
+ handler.navigate('a2');
448
+
449
+ expect(handler.messages.value.map((m) => m.id)).toEqual([
450
+ 'u1',
451
+ 'a2',
452
+ 'u2',
453
+ ]);
454
+ expect(handler.currentLeafId.value).toBe('u2');
455
+ });
456
+
457
+ it('exposes sibling metadata on branched messages', () => {
458
+ const handler = useMessageHandler();
459
+ handler.setTree(
460
+ makeTree(
461
+ {
462
+ u1: makeTreeMessage({ id: 'u1', content: 'Hello' }),
463
+ a1: makeTreeMessage({
464
+ id: 'a1',
465
+ parentId: 'u1',
466
+ role: 'assistant',
467
+ content: 'Hi',
468
+ }),
469
+ a2: makeTreeMessage({
470
+ id: 'a2',
471
+ parentId: 'u1',
472
+ role: 'assistant',
473
+ content: 'Hey',
474
+ }),
475
+ },
476
+ 'u1',
477
+ ),
478
+ );
479
+
480
+ const a1Msg = handler.messages.value.find((m) => m.id === 'a1')!;
481
+ expect(a1Msg.siblingIds).toEqual(['a1', 'a2']);
482
+ expect(a1Msg.siblingIndex).toBe(0);
483
+ });
484
+ });
485
+
486
+ describe('currentLeafId', () => {
487
+ it('is null when there are no messages', () => {
488
+ const handler = useMessageHandler();
489
+ expect(handler.currentLeafId.value).toBeNull();
490
+ });
491
+
492
+ it('returns the id of the last message on the active path', () => {
493
+ const handler = useMessageHandler();
494
+ handler.setCurrentUserMessage('Hello');
495
+ const msg = handler.commitUserMessage();
496
+
497
+ expect(handler.currentLeafId.value).toBe(msg.id);
498
+ });
499
+
500
+ it('updates after navigate', () => {
501
+ const handler = useMessageHandler();
502
+ handler.setTree(
503
+ makeTree(
504
+ {
505
+ u1: makeTreeMessage({ id: 'u1', content: 'Hello' }),
506
+ a1: makeTreeMessage({
507
+ id: 'a1',
508
+ parentId: 'u1',
509
+ role: 'assistant',
510
+ content: 'Hi',
511
+ }),
512
+ a2: makeTreeMessage({
513
+ id: 'a2',
514
+ parentId: 'u1',
515
+ role: 'assistant',
516
+ content: 'Hey',
517
+ }),
518
+ u2: makeTreeMessage({
519
+ id: 'u2',
520
+ parentId: 'a2',
521
+ content: 'Follow-up',
522
+ }),
523
+ },
524
+ 'u1',
525
+ ),
526
+ );
527
+
528
+ expect(handler.currentLeafId.value).toBe('a1');
529
+
530
+ handler.navigate('a2');
531
+
532
+ expect(handler.currentLeafId.value).toBe('u2');
533
+ });
534
+ });
535
+ });