@aleph-alpha/chat-kit 1.15.0 → 1.15.1

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 (36) hide show
  1. package/dist/{CkInput.vue_vue_type_script_setup_true_lang-D8In_Dnl.js → CkInput.vue_vue_type_script_setup_true_lang-DedLzDoO.js} +134 -65
  2. package/dist/components/base/CkHistoryItem/CkHistoryItem.stories.d.ts.map +1 -1
  3. package/dist/components/base/CkHistoryItem/CkHistoryItem.vue.d.ts.map +1 -1
  4. package/dist/components/base/CkHistoryWrapper/CkHistoryWrapper.stories.d.ts.map +1 -1
  5. package/dist/components/composed/CkHistory/CkHistory.stories.d.ts +15 -0
  6. package/dist/components/composed/CkHistory/CkHistory.stories.d.ts.map +1 -1
  7. package/dist/components/composed/CkHistory/CkHistory.vue.d.ts +6 -0
  8. package/dist/components/composed/CkHistory/CkHistory.vue.d.ts.map +1 -1
  9. package/dist/components/composed/CkHistory/types.d.ts +33 -0
  10. package/dist/components/composed/CkHistory/types.d.ts.map +1 -1
  11. package/dist/components/index.js +1 -1
  12. package/dist/handlers/index.js +1 -1
  13. package/dist/handlers/useChatKitLabels.d.ts +5 -0
  14. package/dist/handlers/useChatKitLabels.d.ts.map +1 -1
  15. package/dist/helpers/formatRelativeTime.d.ts +10 -0
  16. package/dist/helpers/formatRelativeTime.d.ts.map +1 -0
  17. package/dist/helpers/index.d.ts +1 -0
  18. package/dist/helpers/index.d.ts.map +1 -1
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +3 -2
  22. package/dist/{useChatKitLabels-BxNkTzr2.js → useChatKitLabels-BC6ItJc6.js} +2 -1
  23. package/package.json +1 -1
  24. package/src/components/base/CkHistoryItem/CkHistoryItem.spec.ts +0 -2
  25. package/src/components/base/CkHistoryItem/CkHistoryItem.stories.ts +0 -6
  26. package/src/components/base/CkHistoryItem/CkHistoryItem.vue +2 -3
  27. package/src/components/base/CkHistoryWrapper/CkHistoryWrapper.stories.ts +23 -23
  28. package/src/components/composed/CkHistory/CkHistory.spec.ts +107 -0
  29. package/src/components/composed/CkHistory/CkHistory.stories.ts +91 -3
  30. package/src/components/composed/CkHistory/CkHistory.vue +94 -44
  31. package/src/components/composed/CkHistory/types.ts +33 -0
  32. package/src/handlers/useChatKitLabels.spec.ts +1 -0
  33. package/src/handlers/useChatKitLabels.ts +6 -0
  34. package/src/helpers/formatRelativeTime.ts +49 -0
  35. package/src/helpers/index.ts +1 -0
  36. package/src/index.ts +3 -0
@@ -33,10 +33,14 @@ import { CkHistoryWrapper, CkHistoryItem } from '@aleph-alpha/chat-kit';
33
33
 
34
34
  <template>
35
35
  <CkHistoryWrapper>
36
- <CkHistoryItem title="Project Proposal Overview" meta-start="Today" meta-end="Innsbruck" />
37
- <CkHistoryItem title="Project Proposal Overview" meta-start="Today" meta-end="Innsbruck" is-active />
38
- <CkHistoryItem title="Project Proposal Overview" meta-start="Today" meta-end="Innsbruck" />
39
- <CkHistoryItem title="Project Proposal Overview" meta-start="Today" meta-end="Innsbruck" />
36
+ <CkHistoryItem title="Project Proposal Overview" meta-start="Today" is-active />
37
+ <CkHistoryItem title="Customer onboarding flow" meta-start="Yesterday" />
38
+ <CkHistoryItem title="Refactor notes" meta-start="2 days ago" />
39
+ <CkHistoryItem title="Quarterly review prep" meta-start="3 days ago" />
40
+ <CkHistoryItem title="Sprint retro notes" meta-start="4 days ago" />
41
+ <CkHistoryItem title="Travel plans 2026" meta-start="1 week ago" />
42
+ <CkHistoryItem title="API design review" meta-start="2 weeks ago" />
43
+ <CkHistoryItem title="Bug triage session" meta-start="3 weeks ago" />
40
44
  </CkHistoryWrapper>
41
45
  </template>`;
42
46
 
@@ -48,7 +52,10 @@ import { CkHistoryWrapper, CkHistoryItem } from '@aleph-alpha/chat-kit';
48
52
  <CkHistoryWrapper :with-gap="false">
49
53
  <CkHistoryItem title="Thread A" meta-start="Today" />
50
54
  <CkHistoryItem title="Thread B" meta-start="Yesterday" />
51
- <CkHistoryItem title="Thread C" meta-start="Last week" />
55
+ <CkHistoryItem title="Thread C" meta-start="2 days ago" />
56
+ <CkHistoryItem title="Thread D" meta-start="3 days ago" />
57
+ <CkHistoryItem title="Thread E" meta-start="4 days ago" />
58
+ <CkHistoryItem title="Thread F" meta-start="1 week ago" />
52
59
  </CkHistoryWrapper>
53
60
  </template>`;
54
61
 
@@ -65,24 +72,15 @@ export const Default: Story = {
65
72
  {
66
73
  title: 'Project Proposal Overview',
67
74
  metaStart: 'Today',
68
- metaEnd: 'Innsbruck',
69
- },
70
- {
71
- title: 'Project Proposal Overview',
72
- metaStart: 'Today',
73
- metaEnd: 'Innsbruck',
74
75
  isActive: true,
75
76
  },
76
- {
77
- title: 'Project Proposal Overview',
78
- metaStart: 'Today',
79
- metaEnd: 'Innsbruck',
80
- },
81
- {
82
- title: 'Project Proposal Overview',
83
- metaStart: 'Today',
84
- metaEnd: 'Innsbruck',
85
- },
77
+ { title: 'Customer onboarding flow', metaStart: 'Yesterday' },
78
+ { title: 'Refactor notes', metaStart: '2 days ago' },
79
+ { title: 'Quarterly review prep', metaStart: '3 days ago' },
80
+ { title: 'Sprint retro notes', metaStart: '4 days ago' },
81
+ { title: 'Travel plans 2026', metaStart: '1 week ago' },
82
+ { title: 'API design review', metaStart: '2 weeks ago' },
83
+ { title: 'Bug triage session', metaStart: '3 weeks ago' },
86
84
  ];
87
85
 
88
86
  function handleActionClick() {
@@ -99,7 +97,6 @@ export const Default: Story = {
99
97
  :key="index"
100
98
  :title="item.title"
101
99
  :meta-start="item.metaStart"
102
- :meta-end="item.metaEnd"
103
100
  :is-active="item.isActive"
104
101
  action-tooltip="More actions"
105
102
  @action-click="handleActionClick"
@@ -128,7 +125,10 @@ export const WithoutGap: Story = {
128
125
  const items = [
129
126
  { title: 'Thread A', metaStart: 'Today' },
130
127
  { title: 'Thread B', metaStart: 'Yesterday' },
131
- { title: 'Thread C', metaStart: 'Last week' },
128
+ { title: 'Thread C', metaStart: '2 days ago' },
129
+ { title: 'Thread D', metaStart: '3 days ago' },
130
+ { title: 'Thread E', metaStart: '4 days ago' },
131
+ { title: 'Thread F', metaStart: '1 week ago' },
132
132
  ];
133
133
  return { args, items };
134
134
  },
@@ -170,12 +170,119 @@ describe('CkHistory', () => {
170
170
  expect(emitted().select).toBeUndefined();
171
171
  });
172
172
 
173
+ it('renders a relative time label under each row by default', () => {
174
+ const conversations = [
175
+ makeConversation({ id: 'c-1', updatedAt: NOW - 60 * 60 * 24 }),
176
+ makeConversation({ id: 'c-2', updatedAt: NOW - 60 * 60 * 24 * 3 }),
177
+ ];
178
+
179
+ render(CkHistory, {
180
+ props: {
181
+ conversations,
182
+ hrefResolver,
183
+ timeFormatter: (s: number) => {
184
+ const days = Math.round((NOW - s) / (60 * 60 * 24));
185
+ return days === 1 ? 'Yesterday' : `${days} days ago`;
186
+ },
187
+ },
188
+ });
189
+
190
+ expect(screen.getByText('Yesterday')).toBeInTheDocument();
191
+ expect(screen.getByText('3 days ago')).toBeInTheDocument();
192
+ });
193
+
194
+ it('hides the time label when timeFormatter returns an empty string', () => {
195
+ const conversations = [makeConversation({ id: 'c-1', updatedAt: NOW })];
196
+
197
+ const { container } = render(CkHistory, {
198
+ props: {
199
+ conversations,
200
+ hrefResolver,
201
+ timeFormatter: () => '',
202
+ },
203
+ });
204
+
205
+ const metaElements = container.querySelectorAll('.label-12-medium');
206
+ expect(metaElements).toHaveLength(0);
207
+ });
208
+
209
+ describe('collapsed limit / Show all', () => {
210
+ const manyConversations = Array.from({ length: 8 }, (_, i) =>
211
+ makeConversation({ id: `c-${i + 1}`, title: `Thread ${i + 1}` }),
212
+ );
213
+
214
+ it('shows only collapsedLimit rows and a "Show all" button when there are more conversations', () => {
215
+ render(CkHistory, {
216
+ props: {
217
+ conversations: manyConversations,
218
+ hrefResolver,
219
+ collapsedLimit: 5,
220
+ },
221
+ });
222
+
223
+ const links = screen.getAllByRole('link');
224
+ expect(links).toHaveLength(5);
225
+ expect(screen.getByTestId('ck-history-show-all')).toBeInTheDocument();
226
+ });
227
+
228
+ it('does not show the "Show all" button when conversations fit within the limit', () => {
229
+ render(CkHistory, {
230
+ props: {
231
+ conversations: manyConversations.slice(0, 3),
232
+ hrefResolver,
233
+ collapsedLimit: 5,
234
+ },
235
+ });
236
+
237
+ expect(
238
+ screen.queryByTestId('ck-history-show-all'),
239
+ ).not.toBeInTheDocument();
240
+ });
241
+
242
+ it('reveals all conversations when "Show all" is clicked', async () => {
243
+ const user = userEvent.setup();
244
+
245
+ render(CkHistory, {
246
+ props: {
247
+ conversations: manyConversations,
248
+ hrefResolver,
249
+ collapsedLimit: 5,
250
+ },
251
+ });
252
+
253
+ expect(screen.getAllByRole('link')).toHaveLength(5);
254
+
255
+ await user.click(screen.getByTestId('ck-history-show-all'));
256
+
257
+ expect(screen.getAllByRole('link')).toHaveLength(8);
258
+ expect(
259
+ screen.queryByTestId('ck-history-show-all'),
260
+ ).not.toBeInTheDocument();
261
+ });
262
+
263
+ it('shows all conversations when collapsedLimit is 0', () => {
264
+ render(CkHistory, {
265
+ props: {
266
+ conversations: manyConversations,
267
+ hrefResolver,
268
+ collapsedLimit: 0,
269
+ },
270
+ });
271
+
272
+ expect(screen.getAllByRole('link')).toHaveLength(8);
273
+ expect(
274
+ screen.queryByTestId('ck-history-show-all'),
275
+ ).not.toBeInTheDocument();
276
+ });
277
+ });
278
+
173
279
  describe('#item slot', () => {
174
280
  interface ItemSlotProps {
175
281
  conversation: Conversation;
176
282
  href: string;
177
283
  isActive: boolean;
178
284
  title: string;
285
+ time: string | undefined;
179
286
  select: (event: MouseEvent) => void;
180
287
  edit: () => void;
181
288
  delete: () => void;
@@ -35,14 +35,38 @@ const sampleConversations: Conversation[] = [
35
35
  id: 'c-5',
36
36
  title: 'Quarterly review prep',
37
37
  createdAt: NOW - ONE_DAY * 30,
38
- updatedAt: NOW - ONE_DAY * 5,
38
+ updatedAt: NOW - ONE_DAY * 3,
39
39
  },
40
40
  {
41
41
  id: 'c-6',
42
42
  title: 'Travel plans 2026',
43
43
  createdAt: NOW - ONE_DAY * 60,
44
+ updatedAt: NOW - ONE_DAY * 4,
45
+ },
46
+ {
47
+ id: 'c-7',
48
+ title: 'Sprint retro notes',
49
+ createdAt: NOW - ONE_DAY * 20,
50
+ updatedAt: NOW - ONE_DAY * 5,
51
+ },
52
+ {
53
+ id: 'c-8',
54
+ title: 'API design review',
55
+ createdAt: NOW - ONE_DAY * 25,
56
+ updatedAt: NOW - ONE_DAY * 7,
57
+ },
58
+ {
59
+ id: 'c-9',
60
+ title: 'Bug triage session',
61
+ createdAt: NOW - ONE_DAY * 40,
44
62
  updatedAt: NOW - ONE_DAY * 14,
45
63
  },
64
+ {
65
+ id: 'c-10',
66
+ title: 'Team standup summary',
67
+ createdAt: NOW - ONE_DAY * 50,
68
+ updatedAt: NOW - ONE_DAY * 21,
69
+ },
46
70
  ];
47
71
 
48
72
  const defaultHrefResolver = (id: string) => `/chat/${id}`;
@@ -66,6 +90,8 @@ const meta: Meta<typeof CkHistory> = {
66
90
  activeId: { control: 'text' },
67
91
  showAction: { control: 'boolean' },
68
92
  showActionOnHover: { control: 'boolean' },
93
+ collapsedLimit: { control: 'number' },
94
+ maxHeight: { control: 'text' },
69
95
  },
70
96
  };
71
97
 
@@ -184,7 +210,7 @@ function onDelete(c: Conversation) { /* confirm + delete */ }
184
210
  @delete="onDelete"
185
211
  >
186
212
  <template
187
- #item="{ conversation, isActive, title, edit, delete: del }"
213
+ #item="{ conversation, isActive, title, time, edit, delete: del }"
188
214
  >
189
215
  <RouterLink
190
216
  :to="{ name: 'chat', params: { id: conversation.id } }"
@@ -196,6 +222,7 @@ function onDelete(c: Conversation) { /* confirm + delete */ }
196
222
  :href="href"
197
223
  :is-active="isActive"
198
224
  :title="title"
225
+ :meta-start="time"
199
226
  action-popover
200
227
  @click="navigate"
201
228
  >
@@ -209,6 +236,22 @@ function onDelete(c: Conversation) { /* confirm + delete */ }
209
236
  </CkHistory>
210
237
  </template>`;
211
238
 
239
+ const withoutTimeTemplateSource = `<script setup lang="ts">
240
+ import { CkHistory, type Conversation } from '@aleph-alpha/chat-kit';
241
+
242
+ const conversations: Conversation[] = [/* ... */];
243
+ const hrefResolver = (id: string) => \`/chat/\${id}\`;
244
+ </script>
245
+
246
+ <template>
247
+ <CkHistory
248
+ :conversations="conversations"
249
+ :href-resolver="hrefResolver"
250
+ active-id="c-2"
251
+ :time-formatter="undefined"
252
+ />
253
+ </template>`;
254
+
212
255
  const renderStory = (args: Story['args']) => ({
213
256
  components: { CkHistory },
214
257
  setup() {
@@ -329,13 +372,14 @@ export const WithCustomItem: Story = {
329
372
  @select="onSelect"
330
373
  >
331
374
  <template
332
- #item="{ href, isActive, title, select, edit, delete: del }"
375
+ #item="{ href, isActive, title, time, select, edit, delete: del }"
333
376
  >
334
377
  <CkHistoryItem
335
378
  as="a"
336
379
  :href="href"
337
380
  :is-active="isActive"
338
381
  :title="title"
382
+ :meta-start="time"
339
383
  action-popover
340
384
  action-aria-label="Conversation options"
341
385
  @click="select($event)"
@@ -371,3 +415,47 @@ export const WithCustomItem: Story = {
371
415
  docs: { source: { code: withCustomItemTemplateSource } },
372
416
  },
373
417
  };
418
+
419
+ /**
420
+ * Hides the relative time label by setting `time-formatter` to `undefined`.
421
+ * Rows display only the title (the pre-v1.14 default).
422
+ */
423
+ export const WithoutTime: Story = {
424
+ args: {
425
+ conversations: sampleConversations,
426
+ hrefResolver: defaultHrefResolver,
427
+ activeId: 'c-2',
428
+ timeFormatter: undefined,
429
+ },
430
+ render: renderStory,
431
+ parameters: { docs: { source: { code: withoutTimeTemplateSource } } },
432
+ };
433
+
434
+ /**
435
+ * With 10 conversations and a `collapsedLimit` of 5, only the first 5 rows
436
+ * are shown initially. A "Show all" button at the bottom reveals the rest
437
+ * and makes the list scrollable (capped at `maxHeight`).
438
+ */
439
+ export const CollapsedWithShowAll: Story = {
440
+ args: {
441
+ conversations: sampleConversations,
442
+ hrefResolver: defaultHrefResolver,
443
+ activeId: 'c-2',
444
+ collapsedLimit: 5,
445
+ maxHeight: '24rem',
446
+ },
447
+ render: renderStory,
448
+ };
449
+
450
+ /**
451
+ * All conversations visible without collapse — set `collapsedLimit` to `0`.
452
+ */
453
+ export const NoCollapse: Story = {
454
+ args: {
455
+ conversations: sampleConversations,
456
+ hrefResolver: defaultHrefResolver,
457
+ activeId: 'c-2',
458
+ collapsedLimit: 0,
459
+ },
460
+ render: renderStory,
461
+ };
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { computed, ref } from 'vue';
2
3
  import {
3
4
  UiIcon,
4
5
  UiSidebarPopoverMenu,
@@ -7,6 +8,7 @@ import {
7
8
  import CkHistoryItem from '../../base/CkHistoryItem/CkHistoryItem.vue';
8
9
  import CkHistoryWrapper from '../../base/CkHistoryWrapper/CkHistoryWrapper.vue';
9
10
  import { useChatKitLabels } from '../../../handlers/useChatKitLabels';
11
+ import { formatRelativeTime } from '../../../helpers/formatRelativeTime';
10
12
  import type { Conversation } from '../../../types';
11
13
  import type { CkHistoryEmits, CkHistoryProps } from './types';
12
14
 
@@ -16,6 +18,9 @@ const props = withDefaults(defineProps<CkHistoryProps>(), {
16
18
  activeId: undefined,
17
19
  showAction: true,
18
20
  showActionOnHover: true,
21
+ timeFormatter: formatRelativeTime,
22
+ collapsedLimit: 5,
23
+ maxHeight: undefined,
19
24
  });
20
25
 
21
26
  const emit = defineEmits<CkHistoryEmits>();
@@ -31,6 +36,8 @@ defineSlots<{
31
36
  * - `href` — URL produced by `hrefResolver(conversation.id)`
32
37
  * - `isActive` — whether this row matches `activeId`
33
38
  * - `title` — resolved title (falls back to `historyItemUntitled` label)
39
+ * - `time` — formatted relative time string from `timeFormatter`, or
40
+ * `undefined` when no formatter is set
34
41
  * - `select(event)` — RouterLink-style click guard: `preventDefault()`
35
42
  * and emit `select` for plain left-clicks; ignores middle/modifier
36
43
  * clicks so native browser affordances still work.
@@ -41,6 +48,7 @@ defineSlots<{
41
48
  href: string;
42
49
  isActive: boolean;
43
50
  title: string;
51
+ time: string | undefined;
44
52
  select: (event: MouseEvent) => void;
45
53
  edit: () => void;
46
54
  delete: () => void;
@@ -49,6 +57,23 @@ defineSlots<{
49
57
 
50
58
  const labels = useChatKitLabels();
51
59
 
60
+ const isExpanded = ref(false);
61
+
62
+ const canCollapse = computed(
63
+ () =>
64
+ props.collapsedLimit > 0 &&
65
+ props.conversations.length > props.collapsedLimit,
66
+ );
67
+
68
+ const visibleConversations = computed(() => {
69
+ if (!canCollapse.value || isExpanded.value) return props.conversations;
70
+ return props.conversations.slice(0, props.collapsedLimit);
71
+ });
72
+
73
+ function toggleExpanded() {
74
+ isExpanded.value = !isExpanded.value;
75
+ }
76
+
52
77
  function hrefFor(c: Conversation) {
53
78
  return props.hrefResolver(c.id);
54
79
  }
@@ -58,6 +83,10 @@ function titleFor(c: Conversation) {
58
83
  return title ? title : labels.value.historyItemUntitled;
59
84
  }
60
85
 
86
+ function timeFor(c: Conversation): string | undefined {
87
+ return props.timeFormatter?.(c.updatedAt);
88
+ }
89
+
61
90
  /**
62
91
  * RouterLink-style click guard: only emit `select` for plain left-clicks.
63
92
  * Middle-click, modifier-clicks, and right-click fall through to the
@@ -74,53 +103,74 @@ function handleRowClick(c: Conversation, event: MouseEvent) {
74
103
  </script>
75
104
 
76
105
  <template>
77
- <CkHistoryWrapper>
78
- <template
79
- v-for="conversation in props.conversations"
80
- :key="conversation.id"
106
+ <div>
107
+ <CkHistoryWrapper
108
+ :class="isExpanded && props.maxHeight ? 'overflow-y-auto' : undefined"
109
+ :style="
110
+ isExpanded && props.maxHeight
111
+ ? { maxHeight: props.maxHeight }
112
+ : undefined
113
+ "
81
114
  >
82
- <slot
83
- name="item"
84
- :conversation="conversation"
85
- :href="hrefFor(conversation)"
86
- :is-active="conversation.id === props.activeId"
87
- :title="titleFor(conversation)"
88
- :select="(event: MouseEvent) => handleRowClick(conversation, event)"
89
- :edit="() => emit('edit', conversation)"
90
- :delete="() => emit('delete', conversation)"
115
+ <template
116
+ v-for="conversation in visibleConversations"
117
+ :key="conversation.id"
91
118
  >
92
- <CkHistoryItem
93
- as="a"
119
+ <slot
120
+ name="item"
121
+ :conversation="conversation"
94
122
  :href="hrefFor(conversation)"
95
123
  :is-active="conversation.id === props.activeId"
96
124
  :title="titleFor(conversation)"
97
- :show-action="props.showAction"
98
- :show-action-on-hover="props.showActionOnHover"
99
- :action-popover="props.showAction"
100
- :action-aria-label="labels.historyItemActionAriaLabel"
101
- @click="handleRowClick(conversation, $event)"
102
- ><template #action-popover>
103
- <UiSidebarPopoverMenu :show-footer-separator="false">
104
- <UiSidebarPopoverMenuItem
105
- :label="labels.historyItemEditAction"
106
- @click="emit('edit', conversation)"
107
- >
108
- <template #icon>
109
- <UiIcon name="pencil" size="s" />
110
- </template>
111
- </UiSidebarPopoverMenuItem>
112
- <UiSidebarPopoverMenuItem
113
- :label="labels.historyItemDeleteAction"
114
- @click="emit('delete', conversation)"
115
- >
116
- <template #icon>
117
- <UiIcon name="trash" size="s" />
118
- </template>
119
- </UiSidebarPopoverMenuItem>
120
- </UiSidebarPopoverMenu>
121
- </template>
122
- </CkHistoryItem>
123
- </slot>
124
- </template>
125
- </CkHistoryWrapper>
125
+ :time="timeFor(conversation)"
126
+ :select="(event: MouseEvent) => handleRowClick(conversation, event)"
127
+ :edit="() => emit('edit', conversation)"
128
+ :delete="() => emit('delete', conversation)"
129
+ >
130
+ <CkHistoryItem
131
+ as="a"
132
+ :href="hrefFor(conversation)"
133
+ :is-active="conversation.id === props.activeId"
134
+ :title="titleFor(conversation)"
135
+ :meta-start="timeFor(conversation)"
136
+ :show-action="props.showAction"
137
+ :show-action-on-hover="props.showActionOnHover"
138
+ :action-popover="props.showAction"
139
+ :action-aria-label="labels.historyItemActionAriaLabel"
140
+ @click="handleRowClick(conversation, $event)"
141
+ ><template #action-popover>
142
+ <UiSidebarPopoverMenu :show-footer-separator="false">
143
+ <UiSidebarPopoverMenuItem
144
+ :label="labels.historyItemEditAction"
145
+ @click="emit('edit', conversation)"
146
+ >
147
+ <template #icon>
148
+ <UiIcon name="pencil" size="s" />
149
+ </template>
150
+ </UiSidebarPopoverMenuItem>
151
+ <UiSidebarPopoverMenuItem
152
+ :label="labels.historyItemDeleteAction"
153
+ @click="emit('delete', conversation)"
154
+ >
155
+ <template #icon>
156
+ <UiIcon name="trash" size="s" />
157
+ </template>
158
+ </UiSidebarPopoverMenuItem>
159
+ </UiSidebarPopoverMenu>
160
+ </template>
161
+ </CkHistoryItem>
162
+ </slot>
163
+ </template>
164
+ </CkHistoryWrapper>
165
+
166
+ <button
167
+ v-if="canCollapse && !isExpanded"
168
+ type="button"
169
+ data-testid="ck-history-show-all"
170
+ class="mt-1 w-full rounded-lg px-2 py-2 text-center body-14-medium text-content-on-surface-muted transition-colors hover:bg-background-surface-muted-subtle"
171
+ @click="toggleExpanded"
172
+ >
173
+ {{ labels.historyShowAll }}
174
+ </button>
175
+ </div>
126
176
  </template>
@@ -50,6 +50,39 @@ export interface CkHistoryProps {
50
50
  * @default true
51
51
  */
52
52
  showActionOnHover?: boolean;
53
+ /**
54
+ * Formats a conversation's `updatedAt` timestamp (Unix seconds) into a
55
+ * human-readable relative time string displayed under the title.
56
+ *
57
+ * Defaults to the built-in `formatRelativeTime` which produces English
58
+ * labels like "Just now", "Yesterday", "3 days ago". Pass your own
59
+ * function for locale-aware formatting (e.g. via `Intl.RelativeTimeFormat`
60
+ * or your i18n library).
61
+ *
62
+ * To hide the time label entirely, pass `() => ''`.
63
+ *
64
+ * @default formatRelativeTime
65
+ */
66
+ timeFormatter?: (updatedAtSeconds: number) => string;
67
+ /**
68
+ * Maximum number of conversations shown in collapsed mode. When the
69
+ * array has more items than this limit a "Show all" button is rendered
70
+ * below the visible rows. Clicking it expands the list and makes it
71
+ * scrollable (capped at `maxHeight`).
72
+ *
73
+ * Set to `0` or `undefined` to disable collapsing — all conversations
74
+ * are shown and the list is always scrollable.
75
+ *
76
+ * @default 5
77
+ */
78
+ collapsedLimit?: number;
79
+ /**
80
+ * Maximum height (CSS value) of the list when expanded. Allows the list
81
+ * to scroll when conversations exceed the available space.
82
+ *
83
+ * @default undefined — no max-height constraint
84
+ */
85
+ maxHeight?: string;
53
86
  }
54
87
 
55
88
  /**
@@ -60,6 +60,7 @@ describe('useChatKitLabels', () => {
60
60
  historyItemActionAriaLabel: 'Konversationsoptionen',
61
61
  historyItemEditAction: 'Bearbeiten',
62
62
  historyItemDeleteAction: 'Löschen',
63
+ historyShowAll: 'Alle anzeigen',
63
64
  });
64
65
 
65
66
  let result: ComputedRef<ChatKitLabels> | undefined;
@@ -77,6 +77,11 @@ export type ChatKitLabels = {
77
77
  historyItemEditAction: string;
78
78
  /** Label for the "Delete" action in the `CkHistory` row popover menu. */
79
79
  historyItemDeleteAction: string;
80
+ /**
81
+ * Label for the "Show all" button that appears when `collapsedLimit` is
82
+ * set and more conversations exist than the limit.
83
+ */
84
+ historyShowAll?: string;
80
85
  };
81
86
 
82
87
  /** English defaults used when no labels are provided by the consumer. */
@@ -106,6 +111,7 @@ export const defaultChatKitLabels: ChatKitLabels = {
106
111
  historyItemActionAriaLabel: 'Conversation options',
107
112
  historyItemEditAction: 'Edit',
108
113
  historyItemDeleteAction: 'Delete',
114
+ historyShowAll: 'Show all',
109
115
  };
110
116
 
111
117
  /** Vue injection key for ChatKit labels. */
@@ -0,0 +1,49 @@
1
+ const MINUTE = 60;
2
+ const HOUR = 60 * MINUTE;
3
+ const DAY = 24 * HOUR;
4
+ const WEEK = 7 * DAY;
5
+ const MONTH = 30 * DAY;
6
+ const YEAR = 365 * DAY;
7
+
8
+ /**
9
+ * Convert a Unix-seconds timestamp to a human-readable relative time string.
10
+ *
11
+ * Uses simple threshold-based bucketing (not `Intl.RelativeTimeFormat`) so the
12
+ * output is deterministic and easy to snapshot-test. Consumers who need
13
+ * locale-aware formatting can supply their own formatter via the
14
+ * `timeFormatter` prop on `CkHistory`.
15
+ */
16
+ export function formatRelativeTime(
17
+ unixSeconds: number,
18
+ nowSeconds: number = Math.floor(Date.now() / 1000),
19
+ ): string {
20
+ const delta = Math.max(0, nowSeconds - unixSeconds);
21
+
22
+ if (delta < MINUTE) return 'Just now';
23
+ if (delta < HOUR) {
24
+ const mins = Math.floor(delta / MINUTE);
25
+ return mins === 1 ? '1 minute ago' : `${mins} minutes ago`;
26
+ }
27
+ if (delta < DAY) {
28
+ const hrs = Math.floor(delta / HOUR);
29
+ return hrs === 1 ? '1 hour ago' : `${hrs} hours ago`;
30
+ }
31
+ if (delta < 2 * DAY) return 'Yesterday';
32
+ if (delta < WEEK) {
33
+ const days = Math.floor(delta / DAY);
34
+ return `${days} days ago`;
35
+ }
36
+ if (delta < 2 * WEEK) return '1 week ago';
37
+ if (delta < MONTH) {
38
+ const weeks = Math.floor(delta / WEEK);
39
+ return `${weeks} weeks ago`;
40
+ }
41
+ if (delta < 2 * MONTH) return '1 month ago';
42
+ if (delta < YEAR) {
43
+ const months = Math.floor(delta / MONTH);
44
+ return `${months} months ago`;
45
+ }
46
+
47
+ const years = Math.floor(delta / YEAR);
48
+ return years === 1 ? '1 year ago' : `${years} years ago`;
49
+ }
@@ -1,4 +1,5 @@
1
1
  export * from './conversationTree';
2
+ export * from './formatRelativeTime';
2
3
  export * from './generateId';
3
4
  export * from './secondsToDate';
4
5
  export * from './toError';
package/src/index.ts CHANGED
@@ -106,6 +106,9 @@ export type {
106
106
  CkUserMessageProps,
107
107
  } from './components';
108
108
 
109
+ // Helpers
110
+ export { formatRelativeTime } from './helpers';
111
+
109
112
  // Shared Types
110
113
  export type {
111
114
  BaseMessage,