@aleph-alpha/chat-kit 5.7.0 → 5.9.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 (33) hide show
  1. package/dist/{CkHistory.vue_vue_type_script_setup_true_lang-YHwE2DYx.js → CkHistory.vue_vue_type_script_setup_true_lang-CEqJtMuI.js} +81 -52
  2. package/dist/adapters/responses-api.d.ts.map +1 -1
  3. package/dist/adapters/responses-api.js +46 -33
  4. package/dist/chat-kit.css +1 -1
  5. package/dist/components/base/CkConversationLayout/CkConversationLayout.stories.d.ts +7 -0
  6. package/dist/components/base/CkConversationLayout/CkConversationLayout.stories.d.ts.map +1 -1
  7. package/dist/components/base/CkConversationLayout/CkConversationLayout.vue.d.ts +3 -1
  8. package/dist/components/base/CkConversationLayout/CkConversationLayout.vue.d.ts.map +1 -1
  9. package/dist/components/base/CkModelSelector/CkModelSelector.stories.d.ts +6 -0
  10. package/dist/components/base/CkModelSelector/CkModelSelector.stories.d.ts.map +1 -1
  11. package/dist/components/base/CkModelSelector/CkModelSelector.vue.d.ts.map +1 -1
  12. package/dist/components/base/CkModelSelector/types.d.ts +6 -0
  13. package/dist/components/base/CkModelSelector/types.d.ts.map +1 -1
  14. package/dist/components/composed/CkConversation/CkConversation.stories.d.ts +23 -3
  15. package/dist/components/composed/CkConversation/CkConversation.stories.d.ts.map +1 -1
  16. package/dist/components/composed/CkConversation/CkConversation.vue.d.ts +17 -2
  17. package/dist/components/composed/CkConversation/CkConversation.vue.d.ts.map +1 -1
  18. package/dist/components/composed/CkHistory/CkHistory.vue.d.ts +1 -1
  19. package/dist/components/index.js +1 -1
  20. package/dist/index.js +1 -1
  21. package/package.json +1 -1
  22. package/src/adapters/responses-api.spec.ts +6 -3
  23. package/src/adapters/responses-api.ts +62 -35
  24. package/src/components/base/CkConversationLayout/CkConversationLayout.spec.ts +209 -1
  25. package/src/components/base/CkConversationLayout/CkConversationLayout.stories.ts +84 -0
  26. package/src/components/base/CkConversationLayout/CkConversationLayout.vue +59 -21
  27. package/src/components/base/CkModelSelector/CkModelSelector.spec.ts +23 -0
  28. package/src/components/base/CkModelSelector/CkModelSelector.stories.ts +20 -0
  29. package/src/components/base/CkModelSelector/CkModelSelector.vue +8 -1
  30. package/src/components/base/CkModelSelector/types.ts +6 -0
  31. package/src/components/composed/CkConversation/CkConversation.spec.ts +96 -5
  32. package/src/components/composed/CkConversation/CkConversation.stories.ts +328 -4
  33. package/src/components/composed/CkConversation/CkConversation.vue +38 -8
@@ -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
- const gap = parseFloat(getComputedStyle(container).rowGap) || 0;
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 - gap,
135
+ containerHeight.value - contentFromUserToEnd - topMargin,
133
136
  );
134
137
  }
135
138
 
136
139
  /**
137
- * Scroll so the last user message is at the top of the viewport.
138
- * The bottom spacer ensures enough scroll room.
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 scrollUserMessageToTop(): void {
153
+ function scrollMessageToTop(index?: number): void {
141
154
  const container = scrollContainerRef.value;
142
155
  if (!container) return;
143
156
 
144
- hasBottomSpacer.value = true;
145
- spacerHeight.value = containerHeight.value;
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
- const userRows = container.querySelectorAll<HTMLElement>(
149
- '[data-message-row="user"]',
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
- scrollUserMessageToTop();
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 full-viewport spacer set by
202
- // `scrollUserMessageToTop` is never reduced, leaving a viewport-sized gap that
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 gap-8 overflow-y-auto"
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>
@@ -46,6 +46,29 @@ describe('CkModelSelector', () => {
46
46
  expect(emitted('update:selectedModelId')).toEqual([['kimi']]);
47
47
  });
48
48
 
49
+ it('renders a sublabel beneath the label when a model has one', async () => {
50
+ const user = userEvent.setup();
51
+ render(CkModelSelector, {
52
+ props: {
53
+ models: [
54
+ { id: 'kimi', label: 'Kimi' },
55
+ { id: 'phoenix', label: 'Phoenix', sublabel: '20260708-ash' },
56
+ ] satisfies CkModelOption[],
57
+ selectedModelId: 'phoenix',
58
+ },
59
+ });
60
+
61
+ await user.click(screen.getByRole('button', { name: TRIGGER_LABEL }));
62
+
63
+ // The sublabel sits inside the option row alongside its label.
64
+ const phoenix = await screen.findByRole('option', { name: /Phoenix/ });
65
+ expect(phoenix).toHaveTextContent('20260708-ash');
66
+ // A model without a sublabel shows only its label.
67
+ expect(
68
+ await screen.findByRole('option', { name: 'Kimi' }),
69
+ ).toHaveTextContent(/^Kimi$/);
70
+ });
71
+
49
72
  it('does not re-emit when the already-selected model is clicked', async () => {
50
73
  const user = userEvent.setup();
51
74
  const { emitted } = render(CkModelSelector, {
@@ -99,3 +99,23 @@ export const Disabled: Story = {
99
99
  args: { disabled: true },
100
100
  render: renderStory,
101
101
  };
102
+
103
+ /**
104
+ * Rows can carry an optional `sublabel` — a muted second line for secondary
105
+ * metadata like a serving revision or provider. It shows only in the dropdown;
106
+ * the trigger stays label-only. Rows without one render just the label.
107
+ */
108
+ export const WithSublabels: Story = {
109
+ args: {
110
+ models: [
111
+ { id: 'kimi', label: 'Kimi' },
112
+ { id: 'phoenix', label: 'Phoenix', sublabel: '20260708-ash' },
113
+ {
114
+ id: 'phoenix-no-think',
115
+ label: 'Phoenix (no thinking)',
116
+ sublabel: '20260708-ash',
117
+ },
118
+ ],
119
+ },
120
+ render: renderStory,
121
+ };
@@ -98,7 +98,14 @@ function select(id: string) {
98
98
  class="relative flex w-full items-center gap-2 rounded-sm py-2 pr-8 pl-3 text-left body-14-regular text-content-on-surface-primary outline-none hover:bg-background-surface-muted-subtle focus-visible:ring-2 focus-visible:ring-ring"
99
99
  @click="select(model.id)"
100
100
  >
101
- <span class="min-w-0 flex-1 truncate">{{ model.label }}</span>
101
+ <span class="flex min-w-0 flex-1 flex-col">
102
+ <span class="min-w-0 truncate">{{ model.label }}</span>
103
+ <span
104
+ v-if="model.sublabel"
105
+ class="min-w-0 truncate text-xs text-content-on-surface-muted"
106
+ >{{ model.sublabel }}</span
107
+ >
108
+ </span>
102
109
  <UiIcon
103
110
  v-if="model.id === selectedModelId"
104
111
  name="check"
@@ -19,6 +19,12 @@ export interface CkModelOption {
19
19
  id: string;
20
20
  /** Display name shown in the trigger and the option row. */
21
21
  label: string;
22
+ /**
23
+ * Optional secondary line shown muted beneath `label` in the dropdown row
24
+ * (e.g. a serving revision or provider). Not shown on the trigger, which
25
+ * stays label-only. Rows without one render just the label.
26
+ */
27
+ sublabel?: string;
22
28
  }
23
29
 
24
30
  export interface CkModelSelectorProps {
@@ -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 { createCommentVNode, h } from 'vue';
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 default code block when agent-message-code-block slot is not provided', () => {
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 codeEl = container.querySelector('pre > code');
660
- expect(codeEl).not.toBeNull();
661
- expect(codeEl?.textContent).toContain('console.log("hi");');
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
  });