@aleph-alpha/chat-kit 5.8.0 → 5.10.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.
- package/dist/{CkHistory.vue_vue_type_script_setup_true_lang-DG96wNug.js → CkHistory.vue_vue_type_script_setup_true_lang-CEqJtMuI.js} +61 -40
- package/dist/adapters/responses-api.d.ts +1 -3
- package/dist/adapters/responses-api.d.ts.map +1 -1
- package/dist/adapters/responses-api.js +55 -65
- package/dist/chat-kit.css +1 -1
- package/dist/components/base/CkConversationLayout/CkConversationLayout.stories.d.ts +7 -0
- package/dist/components/base/CkConversationLayout/CkConversationLayout.stories.d.ts.map +1 -1
- package/dist/components/base/CkConversationLayout/CkConversationLayout.vue.d.ts +3 -1
- package/dist/components/base/CkConversationLayout/CkConversationLayout.vue.d.ts.map +1 -1
- package/dist/components/composed/CkConversation/CkConversation.stories.d.ts +23 -3
- package/dist/components/composed/CkConversation/CkConversation.stories.d.ts.map +1 -1
- package/dist/components/composed/CkConversation/CkConversation.vue.d.ts +17 -2
- package/dist/components/composed/CkConversation/CkConversation.vue.d.ts.map +1 -1
- package/dist/components/composed/CkHistory/CkHistory.vue.d.ts +1 -1
- package/dist/components/index.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +3 -3
- package/src/adapters/arena.spec.ts +12 -18
- package/src/adapters/responses-api.spec.ts +6 -3
- package/src/adapters/responses-api.ts +80 -86
- package/src/components/base/CkConversationLayout/CkConversationLayout.spec.ts +209 -1
- package/src/components/base/CkConversationLayout/CkConversationLayout.stories.ts +84 -0
- package/src/components/base/CkConversationLayout/CkConversationLayout.vue +59 -21
- package/src/components/composed/CkConversation/CkConversation.spec.ts +96 -5
- package/src/components/composed/CkConversation/CkConversation.stories.ts +328 -4
- package/src/components/composed/CkConversation/CkConversation.vue +38 -8
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import CkConversationLayout from './CkConversationLayout.vue';
|
|
2
2
|
import { fireEvent, render, screen } from '@testing-library/vue';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type Component,
|
|
5
|
+
defineComponent,
|
|
6
|
+
h,
|
|
7
|
+
nextTick,
|
|
8
|
+
onMounted,
|
|
9
|
+
ref,
|
|
10
|
+
} from 'vue';
|
|
11
|
+
|
|
12
|
+
type LayoutInstance = { scrollToMessage: (index?: number) => void };
|
|
4
13
|
|
|
5
14
|
const messages = [
|
|
6
15
|
{ role: 'user', content: 'Hello there' },
|
|
@@ -37,6 +46,46 @@ function renderLayout(
|
|
|
37
46
|
return render(Wrapper);
|
|
38
47
|
}
|
|
39
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Render the layout and capture the child component instance so the exposed
|
|
51
|
+
* `scrollToMessage` method can be invoked from the test.
|
|
52
|
+
*/
|
|
53
|
+
function renderWithInstance(
|
|
54
|
+
msgs: Array<{ role: string; content: string }> = messages,
|
|
55
|
+
{ withFooter = false }: { withFooter?: boolean } = {},
|
|
56
|
+
) {
|
|
57
|
+
let instance: LayoutInstance | undefined;
|
|
58
|
+
// eslint-disable-next-line vue/one-component-per-file
|
|
59
|
+
const Wrapper = defineComponent({
|
|
60
|
+
setup() {
|
|
61
|
+
const layoutRef = ref<LayoutInstance>();
|
|
62
|
+
onMounted(() => {
|
|
63
|
+
instance = layoutRef.value;
|
|
64
|
+
});
|
|
65
|
+
return () =>
|
|
66
|
+
h(
|
|
67
|
+
CkConversationLayout as unknown as Component,
|
|
68
|
+
{ messages: msgs, ref: layoutRef },
|
|
69
|
+
{
|
|
70
|
+
'user-message': ({ message }: { message: (typeof msgs)[0] }) =>
|
|
71
|
+
h('p', { 'data-testid': 'user-msg' }, message.content),
|
|
72
|
+
'agent-message': ({ message }: { message: (typeof msgs)[0] }) =>
|
|
73
|
+
h('p', { 'data-testid': 'agent-msg' }, message.content),
|
|
74
|
+
...(withFooter
|
|
75
|
+
? {
|
|
76
|
+
'thread-footer': () =>
|
|
77
|
+
h('p', { 'data-testid': 'footer' }, 'footer'),
|
|
78
|
+
}
|
|
79
|
+
: {}),
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const utils = render(Wrapper);
|
|
86
|
+
return { ...utils, getInstance: () => instance! };
|
|
87
|
+
}
|
|
88
|
+
|
|
40
89
|
describe('CkConversationLayout', () => {
|
|
41
90
|
describe('message display', () => {
|
|
42
91
|
it('displays all provided messages', () => {
|
|
@@ -127,6 +176,90 @@ describe('CkConversationLayout', () => {
|
|
|
127
176
|
});
|
|
128
177
|
});
|
|
129
178
|
|
|
179
|
+
describe('row spacing', () => {
|
|
180
|
+
it('gives the first row a smaller top margin than the following rows', () => {
|
|
181
|
+
const { container } = renderLayout();
|
|
182
|
+
|
|
183
|
+
const rows =
|
|
184
|
+
container.querySelectorAll<HTMLElement>('[data-message-row]');
|
|
185
|
+
expect(rows[0].className).toContain('mt-2xl');
|
|
186
|
+
expect(rows[0].className).not.toContain('mt-3xl');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('gives every non-first row the larger top margin', () => {
|
|
190
|
+
const { container } = renderLayout();
|
|
191
|
+
|
|
192
|
+
const rows =
|
|
193
|
+
container.querySelectorAll<HTMLElement>('[data-message-row]');
|
|
194
|
+
for (const row of Array.from(rows).slice(1)) {
|
|
195
|
+
expect(row.className).toContain('mt-3xl');
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('does not use a flex gap for row spacing', () => {
|
|
200
|
+
const { container } = renderLayout();
|
|
201
|
+
|
|
202
|
+
const scrollEl = container.querySelector(
|
|
203
|
+
'[class*="overflow-y-auto"]',
|
|
204
|
+
) as HTMLElement;
|
|
205
|
+
expect(scrollEl.className).not.toContain('gap-8');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('gives the thread-footer the larger top margin when messages exist', () => {
|
|
209
|
+
// eslint-disable-next-line vue/one-component-per-file
|
|
210
|
+
const Wrapper = defineComponent({
|
|
211
|
+
setup() {
|
|
212
|
+
return () =>
|
|
213
|
+
h(
|
|
214
|
+
CkConversationLayout as unknown as Component,
|
|
215
|
+
{ messages },
|
|
216
|
+
{
|
|
217
|
+
'user-message': () => h('p', 'user'),
|
|
218
|
+
'agent-message': () => h('p', 'agent'),
|
|
219
|
+
'thread-footer': () =>
|
|
220
|
+
h('p', { 'data-testid': 'footer' }, 'footer'),
|
|
221
|
+
},
|
|
222
|
+
);
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const { container } = render(Wrapper);
|
|
227
|
+
|
|
228
|
+
const footerRow = container.querySelector(
|
|
229
|
+
'[data-message-row="agent"]:last-of-type',
|
|
230
|
+
) as HTMLElement;
|
|
231
|
+
expect(footerRow.querySelector('[data-testid="footer"]')).not.toBeNull();
|
|
232
|
+
expect(footerRow.className).toContain('mt-3xl');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('gives the thread-footer the smaller top margin when there are no messages', () => {
|
|
236
|
+
// eslint-disable-next-line vue/one-component-per-file
|
|
237
|
+
const Wrapper = defineComponent({
|
|
238
|
+
setup() {
|
|
239
|
+
return () =>
|
|
240
|
+
h(
|
|
241
|
+
CkConversationLayout as unknown as Component,
|
|
242
|
+
{ messages: [] },
|
|
243
|
+
{
|
|
244
|
+
'user-message': () => h('p', 'user'),
|
|
245
|
+
'agent-message': () => h('p', 'agent'),
|
|
246
|
+
'thread-footer': () =>
|
|
247
|
+
h('p', { 'data-testid': 'footer' }, 'footer'),
|
|
248
|
+
},
|
|
249
|
+
);
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const { container } = render(Wrapper);
|
|
254
|
+
|
|
255
|
+
const footerRow = container.querySelector(
|
|
256
|
+
'[data-message-row="agent"]',
|
|
257
|
+
) as HTMLElement;
|
|
258
|
+
expect(footerRow.className).toContain('mt-2xl');
|
|
259
|
+
expect(footerRow.className).not.toContain('mt-3xl');
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
130
263
|
describe('scroll fade indicators', () => {
|
|
131
264
|
function getFadeElements(container: HTMLElement) {
|
|
132
265
|
return {
|
|
@@ -209,4 +342,79 @@ describe('CkConversationLayout', () => {
|
|
|
209
342
|
expect(bottomFade.className).toContain('opacity-0');
|
|
210
343
|
});
|
|
211
344
|
});
|
|
345
|
+
|
|
346
|
+
describe('scrollToMessage', () => {
|
|
347
|
+
// jsdom reports offsetTop as 0, so stub distinct positions per rendered row.
|
|
348
|
+
function stubRowOffsets(container: Element) {
|
|
349
|
+
container
|
|
350
|
+
.querySelectorAll<HTMLElement>('[data-message-row]')
|
|
351
|
+
.forEach((row, i) => {
|
|
352
|
+
Object.defineProperty(row, 'offsetTop', {
|
|
353
|
+
configurable: true,
|
|
354
|
+
value: (i + 1) * 100,
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function setup(options?: { withFooter?: boolean }) {
|
|
360
|
+
const { container, getInstance } = renderWithInstance(messages, options);
|
|
361
|
+
const scrollEl = container.querySelector(
|
|
362
|
+
'[class*="overflow-y-auto"]',
|
|
363
|
+
) as HTMLElement;
|
|
364
|
+
const scrollTo = vi.fn();
|
|
365
|
+
scrollEl.scrollTo = scrollTo;
|
|
366
|
+
stubRowOffsets(container);
|
|
367
|
+
|
|
368
|
+
return { getInstance, scrollTo };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
it('scrolls to the last message by default', async () => {
|
|
372
|
+
// Four rows (user, agent, user, agent) → last row is at offsetTop 400.
|
|
373
|
+
const { getInstance, scrollTo } = setup();
|
|
374
|
+
|
|
375
|
+
getInstance().scrollToMessage();
|
|
376
|
+
await nextTick();
|
|
377
|
+
|
|
378
|
+
expect(scrollTo).toHaveBeenCalledWith(
|
|
379
|
+
expect.objectContaining({ top: 400, behavior: 'smooth' }),
|
|
380
|
+
);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('scrolls to the message at the given index, regardless of role', async () => {
|
|
384
|
+
const { getInstance, scrollTo } = setup();
|
|
385
|
+
|
|
386
|
+
getInstance().scrollToMessage(1);
|
|
387
|
+
await nextTick();
|
|
388
|
+
|
|
389
|
+
expect(scrollTo).toHaveBeenCalledWith(
|
|
390
|
+
expect.objectContaining({ top: 200, behavior: 'smooth' }),
|
|
391
|
+
);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('treats the thread-footer as the trailing row', async () => {
|
|
395
|
+
// Four messages + footer → the footer is the fifth row at offsetTop 500.
|
|
396
|
+
const { getInstance, scrollTo } = setup({ withFooter: true });
|
|
397
|
+
|
|
398
|
+
getInstance().scrollToMessage();
|
|
399
|
+
await nextTick();
|
|
400
|
+
|
|
401
|
+
expect(scrollTo).toHaveBeenCalledWith(
|
|
402
|
+
expect.objectContaining({ top: 500, behavior: 'smooth' }),
|
|
403
|
+
);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('does nothing when there are no messages', async () => {
|
|
407
|
+
const { container, getInstance } = renderWithInstance([]);
|
|
408
|
+
const scrollEl = container.querySelector(
|
|
409
|
+
'[class*="overflow-y-auto"]',
|
|
410
|
+
) as HTMLElement;
|
|
411
|
+
const scrollTo = vi.fn();
|
|
412
|
+
scrollEl.scrollTo = scrollTo;
|
|
413
|
+
|
|
414
|
+
getInstance().scrollToMessage();
|
|
415
|
+
await nextTick();
|
|
416
|
+
|
|
417
|
+
expect(scrollTo).not.toHaveBeenCalled();
|
|
418
|
+
});
|
|
419
|
+
});
|
|
212
420
|
});
|
|
@@ -99,6 +99,40 @@ function streamAgentReply(): void {
|
|
|
99
99
|
</div>
|
|
100
100
|
</template>`;
|
|
101
101
|
|
|
102
|
+
const scrollToMessageTemplateSource = `<script setup lang="ts">
|
|
103
|
+
import { CkConversationLayout } from '@aleph-alpha/chat-kit';
|
|
104
|
+
import { ref, useTemplateRef } from 'vue';
|
|
105
|
+
|
|
106
|
+
interface Message { role: string; content: string }
|
|
107
|
+
const messages: Message[] = [/* ... */];
|
|
108
|
+
|
|
109
|
+
const layout = useTemplateRef('layout');
|
|
110
|
+
const index = ref(0);
|
|
111
|
+
|
|
112
|
+
function scrollToMessage(): void {
|
|
113
|
+
// Zero-based index among all rows (a thread-footer counts as the last row).
|
|
114
|
+
layout.value?.scrollToMessage(index.value);
|
|
115
|
+
}
|
|
116
|
+
</script>
|
|
117
|
+
|
|
118
|
+
<template>
|
|
119
|
+
<div class="flex h-[400px] flex-col">
|
|
120
|
+
<div class="flex items-center gap-2 border-b p-2">
|
|
121
|
+
<label for="msg-index">Message index</label>
|
|
122
|
+
<input id="msg-index" v-model.number="index" type="number" min="0" />
|
|
123
|
+
<button @click="scrollToMessage">Scroll to message</button>
|
|
124
|
+
</div>
|
|
125
|
+
<CkConversationLayout ref="layout" :messages="messages" class="min-h-0 flex-1">
|
|
126
|
+
<template #user-message="{ message }">
|
|
127
|
+
<div class="bg-core-bg-secondary rounded-md px-3 py-2 max-w-[400px]">{{ message.content }}</div>
|
|
128
|
+
</template>
|
|
129
|
+
<template #agent-message="{ message }">
|
|
130
|
+
<div class="px-3 py-2 max-w-[400px]">{{ message.content }}</div>
|
|
131
|
+
</template>
|
|
132
|
+
</CkConversationLayout>
|
|
133
|
+
</div>
|
|
134
|
+
</template>`;
|
|
135
|
+
|
|
102
136
|
const emptyTemplateSource = `<script setup lang="ts">
|
|
103
137
|
import { CkConversationLayout } from '@aleph-alpha/chat-kit';
|
|
104
138
|
|
|
@@ -229,6 +263,56 @@ export const StreamingSimulation: Story = {
|
|
|
229
263
|
parameters: { docs: { source: { code: streamingSimulationTemplateSource } } },
|
|
230
264
|
};
|
|
231
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Programmatic navigation via the exposed `scrollToMessage(index)` method. Enter
|
|
268
|
+
* a zero-based row index and the layout scrolls that message to the top. Index
|
|
269
|
+
* counts all rows regardless of role (a `thread-footer`, when present, is the
|
|
270
|
+
* trailing row); omitting the index scrolls to the last row.
|
|
271
|
+
*/
|
|
272
|
+
export const ScrollToMessage: Story = {
|
|
273
|
+
render: () => ({
|
|
274
|
+
components: { CkConversationLayout },
|
|
275
|
+
setup() {
|
|
276
|
+
const messages = Array.from({ length: 12 }, (_, i) => ({
|
|
277
|
+
role: i % 2 === 0 ? 'user' : 'agent',
|
|
278
|
+
content: `[${i}] ${
|
|
279
|
+
i % 2 === 0 ? 'User' : 'Agent'
|
|
280
|
+
} message number ${i}. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt.`,
|
|
281
|
+
}));
|
|
282
|
+
const layout = ref();
|
|
283
|
+
const index = ref(0);
|
|
284
|
+
function scrollToMessage() {
|
|
285
|
+
layout.value?.scrollToMessage(index.value);
|
|
286
|
+
}
|
|
287
|
+
return { messages, layout, index, scrollToMessage };
|
|
288
|
+
},
|
|
289
|
+
template: `
|
|
290
|
+
<div style="display: flex; flex-direction: column; height: 100%;">
|
|
291
|
+
<div style="display: flex; align-items: center; gap: 8px; padding: 8px; border-bottom: 1px solid #e0e0e0;">
|
|
292
|
+
<label for="msg-index">Message index</label>
|
|
293
|
+
<input id="msg-index" v-model.number="index" type="number" min="0" style="width: 72px; padding: 4px 8px; border: 1px solid #ccc; border-radius: 6px;" />
|
|
294
|
+
<button @click="scrollToMessage" style="padding: 6px 16px; border-radius: 6px; background: #333; color: white; cursor: pointer;">
|
|
295
|
+
Scroll to message
|
|
296
|
+
</button>
|
|
297
|
+
</div>
|
|
298
|
+
<CkConversationLayout ref="layout" :messages="messages" style="flex: 1; min-height: 0;">
|
|
299
|
+
<template #user-message="{ message }">
|
|
300
|
+
<div style="background: #f0f0f0; padding: 8px 12px; border-radius: 12px 0 12px 12px; max-width: 400px;">
|
|
301
|
+
{{ message.content }}
|
|
302
|
+
</div>
|
|
303
|
+
</template>
|
|
304
|
+
<template #agent-message="{ message }">
|
|
305
|
+
<div style="padding: 8px 12px; max-width: 400px;">
|
|
306
|
+
{{ message.content }}
|
|
307
|
+
</div>
|
|
308
|
+
</template>
|
|
309
|
+
</CkConversationLayout>
|
|
310
|
+
</div>
|
|
311
|
+
`,
|
|
312
|
+
}),
|
|
313
|
+
parameters: { docs: { source: { code: scrollToMessageTemplateSource } } },
|
|
314
|
+
};
|
|
315
|
+
|
|
232
316
|
/**
|
|
233
317
|
* Empty state for when there are no messages. Use as an initial state before
|
|
234
318
|
* the first user turn, or when history has been cleared.
|
|
@@ -125,34 +125,66 @@ function updateSpacerHeight(): void {
|
|
|
125
125
|
const lastUserTop = lastUserRow.offsetTop;
|
|
126
126
|
const lastRowBottom = lastRow.offsetTop + lastRow.offsetHeight;
|
|
127
127
|
const contentFromUserToEnd = lastRowBottom - lastUserTop;
|
|
128
|
-
|
|
128
|
+
// The last user message carries a top margin (mt-3xl for non-first rows) that
|
|
129
|
+
// is scrolled into view above it, so it consumes viewport height just like the
|
|
130
|
+
// content does. Account for it here instead of the removed row gap.
|
|
131
|
+
const topMargin = parseFloat(getComputedStyle(lastUserRow).marginTop) || 0;
|
|
129
132
|
|
|
130
133
|
spacerHeight.value = Math.max(
|
|
131
134
|
0,
|
|
132
|
-
containerHeight.value - contentFromUserToEnd -
|
|
135
|
+
containerHeight.value - contentFromUserToEnd - topMargin,
|
|
133
136
|
);
|
|
134
137
|
}
|
|
135
138
|
|
|
136
139
|
/**
|
|
137
|
-
* Scroll so the
|
|
138
|
-
*
|
|
140
|
+
* Scroll so the message at `index` is at the top of the viewport, keeping its
|
|
141
|
+
* top margin in view. `index` is the zero-based position among the rendered
|
|
142
|
+
* rows — regardless of role, and with the `thread-footer` (when present)
|
|
143
|
+
* counting as the trailing row. When omitted it targets the last row.
|
|
144
|
+
*
|
|
145
|
+
* If there isn't a full viewport of content below the target, a bottom spacer
|
|
146
|
+
* is added so it can still reach the top, and auto-scroll resumes so streamed
|
|
147
|
+
* content keeps following — the newest-turn behaviour. Navigating to an earlier
|
|
148
|
+
* message (enough content already below it) adds no spacer and stops following.
|
|
149
|
+
*
|
|
150
|
+
* This only performs the scroll — it does not focus the message. Any
|
|
151
|
+
* accompanying focus handling must be done separately by the caller.
|
|
139
152
|
*/
|
|
140
|
-
function
|
|
153
|
+
function scrollMessageToTop(index?: number): void {
|
|
141
154
|
const container = scrollContainerRef.value;
|
|
142
155
|
if (!container) return;
|
|
143
156
|
|
|
144
|
-
|
|
145
|
-
|
|
157
|
+
const rows = container.querySelectorAll<HTMLElement>('[data-message-row]');
|
|
158
|
+
if (rows.length === 0) return;
|
|
159
|
+
|
|
160
|
+
const targetIndex = index ?? rows.length - 1;
|
|
161
|
+
const targetRow = rows[targetIndex];
|
|
162
|
+
if (!targetRow) return;
|
|
163
|
+
|
|
164
|
+
// Scroll to just above the row so its top margin stays in view.
|
|
165
|
+
const topMargin = parseFloat(getComputedStyle(targetRow).marginTop) || 0;
|
|
166
|
+
const targetTop = targetRow.offsetTop - topMargin;
|
|
167
|
+
|
|
168
|
+
const lastRow = rows[rows.length - 1];
|
|
169
|
+
const contentBottom = lastRow.offsetTop + lastRow.offsetHeight;
|
|
170
|
+
const contentBelowTarget = contentBottom - targetTop;
|
|
171
|
+
const needsSpacer = contentBelowTarget < containerHeight.value;
|
|
172
|
+
|
|
173
|
+
// Size the spacer so the target lands exactly at the top; skip it when there
|
|
174
|
+
// is already enough content below (avoids a trailing empty gap). Clear any
|
|
175
|
+
// spacer left over from a previous near-end navigation so it doesn't leave a
|
|
176
|
+
// gap at the bottom.
|
|
177
|
+
if (needsSpacer) {
|
|
178
|
+
hasBottomSpacer.value = true;
|
|
179
|
+
spacerHeight.value = containerHeight.value - contentBelowTarget;
|
|
180
|
+
} else {
|
|
181
|
+
hasBottomSpacer.value = false;
|
|
182
|
+
spacerHeight.value = 0;
|
|
183
|
+
}
|
|
146
184
|
|
|
147
185
|
nextTick(() => {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
);
|
|
151
|
-
const lastUserRow = userRows[userRows.length - 1];
|
|
152
|
-
if (lastUserRow) {
|
|
153
|
-
lastUserRow.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
154
|
-
}
|
|
155
|
-
isAutoScrollEnabled.value = true;
|
|
186
|
+
container.scrollTo({ top: targetTop, behavior: 'smooth' });
|
|
187
|
+
isAutoScrollEnabled.value = needsSpacer;
|
|
156
188
|
});
|
|
157
189
|
}
|
|
158
190
|
|
|
@@ -177,7 +209,9 @@ function scrollToEndOfContent(): void {
|
|
|
177
209
|
}
|
|
178
210
|
}
|
|
179
211
|
|
|
180
|
-
// When a new user message is added, scroll it to the top of the viewport
|
|
212
|
+
// When a new user message is added, scroll it to the top of the viewport.
|
|
213
|
+
// Its row index matches its position in `messages` (any `thread-footer` is
|
|
214
|
+
// appended after, so it never shifts earlier rows).
|
|
181
215
|
watch(
|
|
182
216
|
() => props.messages.length,
|
|
183
217
|
(newLen, oldLen) => {
|
|
@@ -185,7 +219,7 @@ watch(
|
|
|
185
219
|
const lastMessage = props.messages[newLen - 1];
|
|
186
220
|
if (lastMessage?.role === 'user') {
|
|
187
221
|
nextTick(() => {
|
|
188
|
-
|
|
222
|
+
scrollMessageToTop(newLen - 1);
|
|
189
223
|
updateFades();
|
|
190
224
|
});
|
|
191
225
|
}
|
|
@@ -198,8 +232,8 @@ watch(
|
|
|
198
232
|
// The trailing content can also be the `thread-footer` slot (e.g. streaming
|
|
199
233
|
// preference-comparison lanes), which continues the thread after a trunk whose
|
|
200
234
|
// last message is the shared *user* prompt. That case must run the same
|
|
201
|
-
// spacer-shrink/follow pass — otherwise the
|
|
202
|
-
// `
|
|
235
|
+
// spacer-shrink/follow pass — otherwise the bottom spacer set by
|
|
236
|
+
// `scrollMessageToTop` is never reduced, leaving a viewport-sized gap that
|
|
203
237
|
// pushes the prompt and answers out of view. `updateSpacerHeight` already
|
|
204
238
|
// measures the footer (it carries `data-message-row="agent"`, so it is the
|
|
205
239
|
// `lastRow`); it just needs to be invoked here. The watch fires on every tree
|
|
@@ -222,6 +256,8 @@ watch(
|
|
|
222
256
|
},
|
|
223
257
|
{ deep: true },
|
|
224
258
|
);
|
|
259
|
+
|
|
260
|
+
defineExpose({ scrollToMessage: scrollMessageToTop });
|
|
225
261
|
</script>
|
|
226
262
|
|
|
227
263
|
<template>
|
|
@@ -234,14 +270,15 @@ watch(
|
|
|
234
270
|
/>
|
|
235
271
|
<div
|
|
236
272
|
ref="scrollContainerRef"
|
|
237
|
-
class="relative flex min-h-0 flex-1 flex-col
|
|
273
|
+
class="relative flex min-h-0 flex-1 flex-col overflow-y-auto"
|
|
238
274
|
@scroll="handleScroll"
|
|
239
275
|
>
|
|
240
276
|
<div
|
|
241
|
-
v-for="message in messages"
|
|
277
|
+
v-for="(message, index) in messages"
|
|
242
278
|
:key="message.id"
|
|
243
279
|
:data-message-row="message.role"
|
|
244
280
|
class="min-w-0"
|
|
281
|
+
:class="index === 0 ? 'mt-2xl' : 'mt-3xl'"
|
|
245
282
|
>
|
|
246
283
|
<div v-if="message.role === 'user'" class="flex min-w-0 justify-end">
|
|
247
284
|
<slot name="user-message" :message="message" />
|
|
@@ -256,6 +293,7 @@ watch(
|
|
|
256
293
|
v-if="$slots['thread-footer']"
|
|
257
294
|
data-message-row="agent"
|
|
258
295
|
class="flex min-w-0 justify-start"
|
|
296
|
+
:class="messages.length === 0 ? 'mt-2xl' : 'mt-3xl'"
|
|
259
297
|
>
|
|
260
298
|
<slot name="thread-footer" />
|
|
261
299
|
</div>
|
|
@@ -2,7 +2,16 @@ import type { BaseMessage, MessageContent } from '../../../types';
|
|
|
2
2
|
import CkConversation from './CkConversation.vue';
|
|
3
3
|
import { defaultMarkdownClasses } from '../../base/CkMarkdownRenderer/defaultMarkdownClasses';
|
|
4
4
|
import { render, screen } from '@testing-library/vue';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
createCommentVNode,
|
|
7
|
+
defineComponent,
|
|
8
|
+
h,
|
|
9
|
+
nextTick,
|
|
10
|
+
onMounted,
|
|
11
|
+
ref,
|
|
12
|
+
} from 'vue';
|
|
13
|
+
|
|
14
|
+
type ConversationInstance = { scrollToMessage: (index?: number) => void };
|
|
6
15
|
|
|
7
16
|
const now = new Date();
|
|
8
17
|
|
|
@@ -649,16 +658,98 @@ describe('CkConversation', () => {
|
|
|
649
658
|
expect(container.querySelector('pre > code')).toBeNull();
|
|
650
659
|
});
|
|
651
660
|
|
|
652
|
-
it('renders
|
|
661
|
+
it('renders a UiCodeBlock by default when agent-message-code-block slot is not provided', () => {
|
|
662
|
+
const markdown = '```javascript\nconsole.log("hi");\n```';
|
|
663
|
+
|
|
664
|
+
const { container } = render(CkConversation, {
|
|
665
|
+
props: { messages: [agentMessage({ text: markdown })] },
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
// The default renderer is now UiCodeBlock, not a bare `pre > code`.
|
|
669
|
+
const codeBlock = container.querySelector('.ui-code-block');
|
|
670
|
+
expect(codeBlock).not.toBeNull();
|
|
671
|
+
expect(codeBlock?.textContent).toContain('console.log("hi");');
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it('passes the fenced language through to the default UiCodeBlock', () => {
|
|
653
675
|
const markdown = '```javascript\nconsole.log("hi");\n```';
|
|
654
676
|
|
|
677
|
+
render(CkConversation, {
|
|
678
|
+
props: { messages: [agentMessage({ text: markdown })] },
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
// UiCodeBlock surfaces the language as its header label.
|
|
682
|
+
expect(screen.getByText('javascript')).toBeInTheDocument();
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it('falls back to "text" language when the fence has no language', () => {
|
|
686
|
+
const markdown = '```\nplain code\n```';
|
|
687
|
+
|
|
655
688
|
const { container } = render(CkConversation, {
|
|
656
689
|
props: { messages: [agentMessage({ text: markdown })] },
|
|
657
690
|
});
|
|
658
691
|
|
|
659
|
-
const
|
|
660
|
-
expect(
|
|
661
|
-
expect(
|
|
692
|
+
const codeBlock = container.querySelector('.ui-code-block');
|
|
693
|
+
expect(codeBlock).not.toBeNull();
|
|
694
|
+
expect(codeBlock?.textContent).toContain('plain code');
|
|
695
|
+
});
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
describe('scrollToMessage', () => {
|
|
699
|
+
function renderWithInstance() {
|
|
700
|
+
let instance: ConversationInstance | undefined;
|
|
701
|
+
|
|
702
|
+
const Wrapper = defineComponent({
|
|
703
|
+
setup() {
|
|
704
|
+
const conversationRef = ref<ConversationInstance>();
|
|
705
|
+
onMounted(() => {
|
|
706
|
+
instance = conversationRef.value;
|
|
707
|
+
});
|
|
708
|
+
return () => h(CkConversation, { messages, ref: conversationRef });
|
|
709
|
+
},
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
const { container } = render(Wrapper);
|
|
713
|
+
const scrollEl = container.querySelector(
|
|
714
|
+
'[class*="overflow-y-auto"]',
|
|
715
|
+
) as HTMLElement;
|
|
716
|
+
const scrollTo = vi.fn();
|
|
717
|
+
scrollEl.scrollTo = scrollTo;
|
|
718
|
+
|
|
719
|
+
// jsdom reports offsetTop as 0, so stub distinct positions per row.
|
|
720
|
+
container
|
|
721
|
+
.querySelectorAll<HTMLElement>('[data-message-row]')
|
|
722
|
+
.forEach((row, i) => {
|
|
723
|
+
Object.defineProperty(row, 'offsetTop', {
|
|
724
|
+
configurable: true,
|
|
725
|
+
value: (i + 1) * 100,
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
return { getInstance: () => instance!, scrollTo };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
it('delegates to the layout and scrolls to the last message by default', async () => {
|
|
733
|
+
// Four rows (user, agent, user, agent) → last row is at offsetTop 400.
|
|
734
|
+
const { getInstance, scrollTo } = renderWithInstance();
|
|
735
|
+
|
|
736
|
+
getInstance().scrollToMessage();
|
|
737
|
+
await nextTick();
|
|
738
|
+
|
|
739
|
+
expect(scrollTo).toHaveBeenCalledWith(
|
|
740
|
+
expect.objectContaining({ top: 400, behavior: 'smooth' }),
|
|
741
|
+
);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
it('scrolls to the message at the given index, regardless of role', async () => {
|
|
745
|
+
const { getInstance, scrollTo } = renderWithInstance();
|
|
746
|
+
|
|
747
|
+
getInstance().scrollToMessage(1);
|
|
748
|
+
await nextTick();
|
|
749
|
+
|
|
750
|
+
expect(scrollTo).toHaveBeenCalledWith(
|
|
751
|
+
expect.objectContaining({ top: 200, behavior: 'smooth' }),
|
|
752
|
+
);
|
|
662
753
|
});
|
|
663
754
|
});
|
|
664
755
|
});
|