@aleph-alpha/chat-kit 4.0.0 → 5.0.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 (110) hide show
  1. package/dist/{CkInput.vue_vue_type_script_setup_true_lang-DJN-nZ2-.js → CkHistory.vue_vue_type_script_setup_true_lang-Bp9hXdH6.js} +434 -377
  2. package/dist/chat-kit.css +28 -6
  3. package/dist/components/base/CkAgentMessage/CkAgentMessage.stories.d.ts +14 -13
  4. package/dist/components/base/CkAgentMessage/CkAgentMessage.stories.d.ts.map +1 -1
  5. package/dist/components/base/CkAgentMessage/CkAgentMessage.vue.d.ts +8 -7
  6. package/dist/components/base/CkAgentMessage/CkAgentMessage.vue.d.ts.map +1 -1
  7. package/dist/components/base/CkAgentMessage/types.d.ts +16 -5
  8. package/dist/components/base/CkAgentMessage/types.d.ts.map +1 -1
  9. package/dist/components/base/CkAgentMessageHeader/CkAgentMessageHeader.stories.d.ts +16 -0
  10. package/dist/components/base/CkAgentMessageHeader/CkAgentMessageHeader.stories.d.ts.map +1 -0
  11. package/dist/components/base/CkAgentMessageHeader/CkAgentMessageHeader.vue.d.ts +21 -0
  12. package/dist/components/base/CkAgentMessageHeader/CkAgentMessageHeader.vue.d.ts.map +1 -0
  13. package/dist/components/base/CkAgentMessageHeader/index.d.ts +3 -0
  14. package/dist/components/base/CkAgentMessageHeader/index.d.ts.map +1 -0
  15. package/dist/components/base/CkAgentMessageHeader/types.d.ts +22 -0
  16. package/dist/components/base/CkAgentMessageHeader/types.d.ts.map +1 -0
  17. package/dist/components/base/CkCitation/CkCitation.stories.d.ts +6 -0
  18. package/dist/components/base/CkCitation/CkCitation.stories.d.ts.map +1 -1
  19. package/dist/components/base/CkCitation/CkCitation.vue.d.ts.map +1 -1
  20. package/dist/components/base/CkCitationCard/CkCitationCard.stories.d.ts +5 -4
  21. package/dist/components/base/CkCitationCard/CkCitationCard.stories.d.ts.map +1 -1
  22. package/dist/components/base/CkCitationCard/CkCitationCard.vue.d.ts.map +1 -1
  23. package/dist/components/base/CkFileItemsList/CkFileItemsList.stories.d.ts +15 -4
  24. package/dist/components/base/CkFileItemsList/CkFileItemsList.stories.d.ts.map +1 -1
  25. package/dist/components/base/CkFileItemsList/CkFileItemsList.vue.d.ts +2 -1
  26. package/dist/components/base/CkFileItemsList/CkFileItemsList.vue.d.ts.map +1 -1
  27. package/dist/components/base/CkFileItemsList/types.d.ts +17 -4
  28. package/dist/components/base/CkFileItemsList/types.d.ts.map +1 -1
  29. package/dist/components/base/CkHeaderActivityButton/CkHeaderActivityButton.stories.d.ts +19 -0
  30. package/dist/components/base/CkHeaderActivityButton/CkHeaderActivityButton.stories.d.ts.map +1 -0
  31. package/dist/components/base/CkHeaderActivityButton/CkHeaderActivityButton.vue.d.ts +22 -0
  32. package/dist/components/base/CkHeaderActivityButton/CkHeaderActivityButton.vue.d.ts.map +1 -0
  33. package/dist/components/base/CkHeaderActivityButton/index.d.ts +3 -0
  34. package/dist/components/base/CkHeaderActivityButton/index.d.ts.map +1 -0
  35. package/dist/components/base/CkHeaderActivityButton/types.d.ts +36 -0
  36. package/dist/components/base/CkHeaderActivityButton/types.d.ts.map +1 -0
  37. package/dist/components/base/CkPromptInput/CkPromptInput.stories.d.ts +6 -6
  38. package/dist/components/base/CkPromptInput/CkPromptInput.vue.d.ts.map +1 -1
  39. package/dist/components/base/CkPromptInput/types.d.ts +4 -4
  40. package/dist/components/base/CkPromptInputDisclaimer/CkPromptInputDisclaimer.vue.d.ts.map +1 -1
  41. package/dist/components/base/CkPromptInputDisclaimer/types.d.ts +1 -1
  42. package/dist/components/base/CkPromptSendButton/CkPromptSendButton.vue.d.ts +1 -1
  43. package/dist/components/base/CkPromptSendButton/types.d.ts +1 -1
  44. package/dist/components/base/CkPromptStopButton/types.d.ts +1 -1
  45. package/dist/components/base/index.d.ts +4 -0
  46. package/dist/components/base/index.d.ts.map +1 -1
  47. package/dist/components/composed/CkConversation/CkConversation.vue.d.ts +13 -1
  48. package/dist/components/composed/CkConversation/CkConversation.vue.d.ts.map +1 -1
  49. package/dist/components/composed/CkConversation/types.d.ts +1 -1
  50. package/dist/components/composed/index.d.ts +0 -1
  51. package/dist/components/composed/index.d.ts.map +1 -1
  52. package/dist/components/index.d.ts +3 -3
  53. package/dist/components/index.d.ts.map +1 -1
  54. package/dist/components/index.js +26 -25
  55. package/dist/index.d.ts +2 -2
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +27 -26
  58. package/package.json +1 -1
  59. package/src/components/base/CkAgentMessage/CkAgentMessage.spec.ts +12 -17
  60. package/src/components/base/CkAgentMessage/CkAgentMessage.stories.ts +124 -113
  61. package/src/components/base/CkAgentMessage/CkAgentMessage.vue +18 -16
  62. package/src/components/base/CkAgentMessage/types.ts +16 -5
  63. package/src/components/base/CkAgentMessageHeader/CkAgentMessageHeader.spec.ts +64 -0
  64. package/src/components/base/CkAgentMessageHeader/CkAgentMessageHeader.stories.ts +90 -0
  65. package/src/components/base/CkAgentMessageHeader/CkAgentMessageHeader.vue +47 -0
  66. package/src/components/base/CkAgentMessageHeader/index.ts +2 -0
  67. package/src/components/base/CkAgentMessageHeader/types.ts +21 -0
  68. package/src/components/base/CkCitation/CkCitation.stories.ts +12 -0
  69. package/src/components/base/CkCitation/CkCitation.vue +34 -9
  70. package/src/components/base/CkCitationCard/CkCitationCard.stories.ts +5 -4
  71. package/src/components/base/CkCitationCard/CkCitationCard.vue +2 -2
  72. package/src/components/base/CkConversationLayout/CkConversationLayout.vue +1 -1
  73. package/src/components/base/CkFileItemsList/CkFileItemsList.spec.ts +88 -13
  74. package/src/components/base/CkFileItemsList/CkFileItemsList.stories.ts +61 -13
  75. package/src/components/base/CkFileItemsList/CkFileItemsList.vue +153 -110
  76. package/src/components/base/CkFileItemsList/types.ts +17 -4
  77. package/src/components/base/CkHeaderActivityButton/CkHeaderActivityButton.spec.ts +50 -0
  78. package/src/components/base/CkHeaderActivityButton/CkHeaderActivityButton.stories.ts +102 -0
  79. package/src/components/base/CkHeaderActivityButton/CkHeaderActivityButton.vue +39 -0
  80. package/src/components/base/CkHeaderActivityButton/index.ts +2 -0
  81. package/src/components/base/CkHeaderActivityButton/types.ts +36 -0
  82. package/src/components/base/CkPromptInput/CkPromptInput.spec.ts +88 -0
  83. package/src/components/base/CkPromptInput/CkPromptInput.stories.ts +6 -6
  84. package/src/components/base/CkPromptInput/CkPromptInput.vue +63 -12
  85. package/src/components/base/CkPromptInput/types.ts +4 -4
  86. package/src/components/base/CkPromptInputDisclaimer/CkPromptInputDisclaimer.spec.ts +1 -1
  87. package/src/components/base/CkPromptInputDisclaimer/CkPromptInputDisclaimer.vue +1 -1
  88. package/src/components/base/CkPromptInputDisclaimer/types.ts +1 -1
  89. package/src/components/base/CkPromptSendButton/types.ts +1 -1
  90. package/src/components/base/CkPromptStopButton/types.ts +1 -1
  91. package/src/components/base/index.ts +7 -0
  92. package/src/components/composed/CkConversation/CkConversation.spec.ts +81 -6
  93. package/src/components/composed/CkConversation/CkConversation.vue +78 -35
  94. package/src/components/composed/CkConversation/types.ts +1 -1
  95. package/src/components/composed/index.ts +0 -1
  96. package/src/components/index.ts +6 -1
  97. package/src/index.ts +5 -1
  98. package/dist/components/composed/CkInput/CkInput.stories.d.ts +0 -43
  99. package/dist/components/composed/CkInput/CkInput.stories.d.ts.map +0 -1
  100. package/dist/components/composed/CkInput/CkInput.vue.d.ts +0 -50
  101. package/dist/components/composed/CkInput/CkInput.vue.d.ts.map +0 -1
  102. package/dist/components/composed/CkInput/index.d.ts +0 -3
  103. package/dist/components/composed/CkInput/index.d.ts.map +0 -1
  104. package/dist/components/composed/CkInput/types.d.ts +0 -55
  105. package/dist/components/composed/CkInput/types.d.ts.map +0 -1
  106. package/src/components/composed/CkInput/CkInput.spec.ts +0 -191
  107. package/src/components/composed/CkInput/CkInput.stories.ts +0 -434
  108. package/src/components/composed/CkInput/CkInput.vue +0 -106
  109. package/src/components/composed/CkInput/index.ts +0 -2
  110. package/src/components/composed/CkInput/types.ts +0 -55
@@ -8,7 +8,7 @@ import {
8
8
  UiProgress,
9
9
  type UiFileItemSize,
10
10
  } from '@aleph-alpha/ui-library';
11
- import { computed, ref } from 'vue';
11
+ import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
12
12
  import CkFileItemRow from './CkFileItemRow.vue';
13
13
  import type {
14
14
  CkFileEntry,
@@ -21,7 +21,8 @@ defineOptions({
21
21
  });
22
22
 
23
23
  const props = withDefaults(defineProps<CkFileItemsListProps>(), {
24
- maxInline: 3,
24
+ minItemWidth: 200,
25
+ maxItemWidth: 256,
25
26
  size: 'small',
26
27
  isRemovable: false,
27
28
  removeLabel: 'Remove file',
@@ -48,7 +49,42 @@ defineSlots<{
48
49
  }) => unknown;
49
50
  }>();
50
51
 
51
- const isCollapsed = computed(() => props.files.length > props.maxInline);
52
+ // Gap between inline items (`gap-2` => 0.5rem => 8px).
53
+ const ITEM_GAP = 8;
54
+
55
+ const rootRef = ref<HTMLElement | null>(null);
56
+ const containerWidth = ref(0);
57
+ let resizeObserver: ResizeObserver | undefined;
58
+
59
+ onMounted(() => {
60
+ const el = rootRef.value;
61
+ if (!el) return;
62
+ containerWidth.value = el.clientWidth;
63
+ if (typeof ResizeObserver !== 'undefined') {
64
+ resizeObserver = new ResizeObserver((entries) => {
65
+ for (const entry of entries) {
66
+ containerWidth.value = entry.contentRect.width;
67
+ }
68
+ });
69
+ resizeObserver.observe(el);
70
+ }
71
+ });
72
+
73
+ onBeforeUnmount(() => resizeObserver?.disconnect());
74
+
75
+ // Number of items that fit at the current width while each stays at least
76
+ // `minItemWidth` wide. There is no upper cap — as many items as fit are kept
77
+ // inline. Falls back to showing every file before the first measurement (SSR /
78
+ // initial render / environments without ResizeObserver) so the list never
79
+ // flashes collapsed; the observer corrects it on mount.
80
+ const inlineCapacity = computed(() => {
81
+ const width = containerWidth.value;
82
+ if (!width) return props.files.length;
83
+ const fit = Math.floor((width + ITEM_GAP) / (props.minItemWidth + ITEM_GAP));
84
+ return Math.max(1, fit);
85
+ });
86
+
87
+ const isCollapsed = computed(() => props.files.length > inlineCapacity.value);
52
88
  const isOpen = ref(false);
53
89
 
54
90
  function isRowDisabled(file: CkFileEntry): boolean {
@@ -120,123 +156,130 @@ function handleRowClick(file: CkFileEntry, event: MouseEvent) {
120
156
  </script>
121
157
 
122
158
  <template>
123
- <ul
124
- v-if="!isCollapsed"
125
- data-slot="file-items-list"
126
- class="m-0 flex w-full min-w-0 list-none flex-wrap items-center gap-2 p-0"
127
- :aria-label="listLabel"
128
- >
129
- <li
130
- v-for="file in files"
131
- :key="file.id"
132
- class="min-w-0 max-w-64 flex-1 basis-0"
133
- @click="handleRowClick(file, $event)"
159
+ <div ref="rootRef" class="w-full min-w-0">
160
+ <ul
161
+ v-if="!isCollapsed"
162
+ data-slot="file-items-list"
163
+ class="m-0 flex w-full min-w-0 list-none flex-wrap items-center gap-2 p-0"
164
+ :aria-label="listLabel"
134
165
  >
135
- <CkFileItemRow
136
- :file="file"
137
- :size="size"
138
- :is-removable="isRemovable"
139
- :remove-label="removeLabel"
140
- :disabled="isRowDisabled(file)"
141
- @remove="handleRemove(file)"
142
- >
143
- <template #default="bindings">
144
- <slot name="item" v-bind="bindings" />
145
- </template>
146
- </CkFileItemRow>
147
- </li>
148
- </ul>
149
-
150
- <UiPopover v-else v-model:open="isOpen">
151
- <UiPopoverTrigger as-child>
152
- <button
153
- type="button"
154
- data-slot="file-items-list-trigger"
155
- :disabled="disabled"
156
- class="relative flex w-full max-w-64 min-w-0 items-center gap-3 rounded-xl bg-background-surface-muted-subtle text-left transition-colors hover:bg-background-surface-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-border-focus disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-background-surface-muted-subtle"
157
- :class="size === 'small' ? 'p-2' : 'p-3'"
158
- :aria-label="resolvedTriggerAriaLabel"
166
+ <li
167
+ v-for="file in files"
168
+ :key="file.id"
169
+ class="flex-1 basis-0"
170
+ :style="{
171
+ minWidth: `${minItemWidth}px`,
172
+ maxWidth: `${maxItemWidth}px`,
173
+ }"
174
+ @click="handleRowClick(file, $event)"
159
175
  >
160
- <UiFileType
161
- v-if="showStatus && hasFailures && !uploadInProgress"
162
- :error="true"
163
- />
164
- <UiFileType v-else icon="paperclip" tone="default" />
165
-
166
- <span
167
- v-if="!showStatus"
168
- class="min-w-0 flex-1 truncate text-content-on-surface-primary"
169
- :class="size === 'small' ? 'label-12-medium' : 'body-14-medium'"
176
+ <CkFileItemRow
177
+ :file="file"
178
+ :size="size"
179
+ :is-removable="isRemovable"
180
+ :remove-label="removeLabel"
181
+ :disabled="isRowDisabled(file)"
182
+ @remove="handleRemove(file)"
170
183
  >
171
- {{ resolvedTriggerLabel }}
172
- </span>
173
- <div v-else class="flex min-w-0 flex-1 flex-col items-start gap-0.5">
184
+ <template #default="bindings">
185
+ <slot name="item" v-bind="bindings" />
186
+ </template>
187
+ </CkFileItemRow>
188
+ </li>
189
+ </ul>
190
+
191
+ <UiPopover v-else v-model:open="isOpen">
192
+ <UiPopoverTrigger as-child>
193
+ <button
194
+ type="button"
195
+ data-slot="file-items-list-trigger"
196
+ :disabled="disabled"
197
+ class="relative flex w-full max-w-64 min-w-0 items-center gap-3 rounded-xl bg-background-surface-muted-subtle text-left transition-colors hover:bg-background-surface-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-border-focus disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-background-surface-muted-subtle"
198
+ :class="size === 'small' ? 'p-2' : 'p-3'"
199
+ :aria-label="resolvedTriggerAriaLabel"
200
+ >
201
+ <UiFileType
202
+ v-if="showStatus && hasFailures && !uploadInProgress"
203
+ :error="true"
204
+ />
205
+ <UiFileType v-else icon="paperclip" tone="default" />
206
+
174
207
  <span
175
- class="w-full truncate text-content-on-surface-primary"
208
+ v-if="!showStatus"
209
+ class="min-w-0 flex-1 truncate text-content-on-surface-primary"
176
210
  :class="size === 'small' ? 'label-12-medium' : 'body-14-medium'"
177
211
  >
178
212
  {{ resolvedTriggerLabel }}
179
213
  </span>
180
- <UiProgress
181
- v-if="uploadInProgress"
182
- :model-value="aggregateProgress"
183
- class="h-1 w-full"
184
- :aria-label="`${resolvedTriggerLabel} upload progress`"
214
+ <div v-else class="flex min-w-0 flex-1 flex-col items-start gap-0.5">
215
+ <span
216
+ class="w-full truncate text-content-on-surface-primary"
217
+ :class="size === 'small' ? 'label-12-medium' : 'body-14-medium'"
218
+ >
219
+ {{ resolvedTriggerLabel }}
220
+ </span>
221
+ <UiProgress
222
+ v-if="uploadInProgress"
223
+ :model-value="aggregateProgress"
224
+ class="h-1 w-full"
225
+ :aria-label="`${resolvedTriggerLabel} upload progress`"
226
+ />
227
+ <span
228
+ v-else-if="hasFailures"
229
+ class="label-12-medium w-full truncate text-content-on-status-error-inverse"
230
+ >
231
+ {{ failedStatusLabel }}
232
+ </span>
233
+ <span
234
+ v-else-if="triggerLabel"
235
+ class="label-12-medium w-full truncate text-content-on-surface-muted"
236
+ >
237
+ {{ countStatusLabel }}
238
+ </span>
239
+ </div>
240
+
241
+ <UiIcon
242
+ name="chevron-down"
243
+ :size="16"
244
+ aria-hidden="true"
245
+ class="shrink-0 text-content-on-surface-muted transition-transform"
246
+ :class="isOpen && 'rotate-180'"
185
247
  />
186
- <span
187
- v-else-if="hasFailures"
188
- class="label-12-medium w-full truncate text-content-on-status-error-inverse"
189
- >
190
- {{ failedStatusLabel }}
191
- </span>
192
- <span
193
- v-else-if="triggerLabel"
194
- class="label-12-medium w-full truncate text-content-on-surface-muted"
195
- >
196
- {{ countStatusLabel }}
197
- </span>
198
- </div>
199
-
200
- <UiIcon
201
- name="chevron-down"
202
- :size="16"
203
- aria-hidden="true"
204
- class="shrink-0 text-content-on-surface-muted transition-transform"
205
- :class="isOpen && 'rotate-180'"
206
- />
207
- </button>
208
- </UiPopoverTrigger>
209
-
210
- <UiPopoverContent
211
- align="start"
212
- :side-offset="4"
213
- :class="['w-64 p-2', popoverClass]"
214
- >
215
- <ul
216
- data-slot="file-items-list-collapsed"
217
- class="m-0 flex list-none flex-col gap-2 p-0"
218
- :aria-label="listLabel"
248
+ </button>
249
+ </UiPopoverTrigger>
250
+
251
+ <UiPopoverContent
252
+ align="start"
253
+ :side-offset="4"
254
+ :class="['w-64 p-2', popoverClass]"
219
255
  >
220
- <li
221
- v-for="file in files"
222
- :key="file.id"
223
- class="min-w-0 max-w-64"
224
- @click="handleRowClick(file, $event)"
256
+ <ul
257
+ data-slot="file-items-list-collapsed"
258
+ class="m-0 flex list-none flex-col gap-2 p-0"
259
+ :aria-label="listLabel"
225
260
  >
226
- <CkFileItemRow
227
- :file="file"
228
- :size="size"
229
- :is-removable="isRemovable"
230
- :remove-label="removeLabel"
231
- :disabled="isRowDisabled(file)"
232
- @remove="handleRemove(file)"
261
+ <li
262
+ v-for="file in files"
263
+ :key="file.id"
264
+ class="min-w-0"
265
+ :style="{ maxWidth: `${maxItemWidth}px` }"
266
+ @click="handleRowClick(file, $event)"
233
267
  >
234
- <template #default="bindings">
235
- <slot name="item" v-bind="bindings" />
236
- </template>
237
- </CkFileItemRow>
238
- </li>
239
- </ul>
240
- </UiPopoverContent>
241
- </UiPopover>
268
+ <CkFileItemRow
269
+ :file="file"
270
+ :size="size"
271
+ :is-removable="isRemovable"
272
+ :remove-label="removeLabel"
273
+ :disabled="isRowDisabled(file)"
274
+ @remove="handleRemove(file)"
275
+ >
276
+ <template #default="bindings">
277
+ <slot name="item" v-bind="bindings" />
278
+ </template>
279
+ </CkFileItemRow>
280
+ </li>
281
+ </ul>
282
+ </UiPopoverContent>
283
+ </UiPopover>
284
+ </div>
242
285
  </template>
@@ -25,8 +25,9 @@ export interface CkFileEntry {
25
25
  }
26
26
 
27
27
  /**
28
- * Horizontal list of file attachments; collapses to a popover once the
29
- * count exceeds `maxInline`.
28
+ * Horizontal list of file attachments. Renders as many items inline as fit the
29
+ * available width — each kept between `minItemWidth` and `maxItemWidth` wide —
30
+ * and collapses into a popover once they no longer fit.
30
31
  *
31
32
  * @category Form Inputs
32
33
  * @useCases user message attachments, prompt input previews, chat file row
@@ -36,8 +37,20 @@ export interface CkFileEntry {
36
37
  export interface CkFileItemsListProps {
37
38
  /** Files to render. Order is preserved. */
38
39
  files: CkFileEntry[];
39
- /** @default 3 */
40
- maxInline?: number;
40
+ /**
41
+ * Minimum width (px) an inline item may occupy. As the wrapper narrows, fewer
42
+ * items stay inline (each kept at least this wide) until the list collapses
43
+ * into the popover. Lower values keep more items inline on narrow widths.
44
+ * @default 200
45
+ */
46
+ minItemWidth?: number;
47
+ /**
48
+ * Maximum width (px) an inline item may grow to. Items never stretch beyond
49
+ * this width; any extra horizontal space is left unused. There is no cap on
50
+ * the number of inline items as long as they fit the wrapper.
51
+ * @default 256
52
+ */
53
+ maxItemWidth?: number;
41
54
  /** @default 'small' */
42
55
  size?: UiFileItemSize;
43
56
  /** Show a remove control on every row. @default false */
@@ -0,0 +1,50 @@
1
+ import CkHeaderActivityButton from './CkHeaderActivityButton.vue';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { render, screen } from '@testing-library/vue';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ describe('CkHeaderActivityButton', () => {
7
+ it('renders the slot content as the label', () => {
8
+ render(CkHeaderActivityButton, {
9
+ slots: { default: '1 tool, 42 sources' },
10
+ });
11
+
12
+ expect(
13
+ screen.getByRole('button', { name: /1 tool, 42 sources/ }),
14
+ ).toBeInTheDocument();
15
+ });
16
+
17
+ it('uses the explicit ariaLabel when provided', () => {
18
+ render(CkHeaderActivityButton, {
19
+ props: { ariaLabel: 'Show activity' },
20
+ slots: { default: '1 tool' },
21
+ });
22
+
23
+ expect(
24
+ screen.getByRole('button', { name: 'Show activity' }),
25
+ ).toBeInTheDocument();
26
+ });
27
+
28
+ it('emits click when pressed', async () => {
29
+ const user = userEvent.setup();
30
+ const { emitted } = render(CkHeaderActivityButton, {
31
+ slots: { default: 'Activity' },
32
+ });
33
+
34
+ await user.click(screen.getByRole('button'));
35
+
36
+ expect(emitted('click')).toHaveLength(1);
37
+ });
38
+
39
+ it('does not emit click when disabled', async () => {
40
+ const user = userEvent.setup();
41
+ const { emitted } = render(CkHeaderActivityButton, {
42
+ props: { disabled: true },
43
+ slots: { default: 'Activity' },
44
+ });
45
+
46
+ await user.click(screen.getByRole('button'));
47
+
48
+ expect(emitted('click')).toBeUndefined();
49
+ });
50
+ });
@@ -0,0 +1,102 @@
1
+ import CkHeaderActivityButton from './CkHeaderActivityButton.vue';
2
+ import type { Meta, StoryObj } from '@storybook/vue3-vite';
3
+
4
+ const meta: Meta<typeof CkHeaderActivityButton> = {
5
+ title: 'Base/CkHeaderActivityButton',
6
+ component: CkHeaderActivityButton,
7
+ tags: ['autodocs'],
8
+ argTypes: {
9
+ disabled: {
10
+ control: 'boolean',
11
+ description: 'Disables the button and prevents the `click` event.',
12
+ },
13
+ ariaLabel: {
14
+ control: 'text',
15
+ description:
16
+ 'Accessible label. Falls back to the label text when omitted.',
17
+ },
18
+ icon: {
19
+ control: 'text',
20
+ description:
21
+ "Trailing Lucide icon name. Set to '' to hide. Defaults to 'arrow-right'.",
22
+ },
23
+ },
24
+ decorators: [
25
+ () => ({
26
+ template: '<div style="padding: 24px;"><story /></div>',
27
+ }),
28
+ ],
29
+ };
30
+
31
+ export default meta;
32
+ type Story = StoryObj<typeof meta>;
33
+
34
+ const defaultTemplateSource = `<script setup lang="ts">
35
+ import { CkHeaderActivityButton } from '@aleph-alpha/chat-kit';
36
+
37
+ function openActivity() {
38
+ // open the activity / chain-of-thought panel
39
+ }
40
+ </script>
41
+
42
+ <template>
43
+ <CkHeaderActivityButton @click="openActivity">
44
+ 1 tool, 42 sources
45
+ </CkHeaderActivityButton>
46
+ </template>`;
47
+
48
+ /**
49
+ * Default activity button: a short label followed by the trailing arrow icon.
50
+ * It emits a generic `click` and performs no action on its own.
51
+ */
52
+ export const Default: Story = {
53
+ render: (args) => ({
54
+ components: { CkHeaderActivityButton },
55
+ setup() {
56
+ return { args };
57
+ },
58
+ template: `
59
+ <CkHeaderActivityButton v-bind="args">
60
+ 1 tool, 42 sources
61
+ </CkHeaderActivityButton>
62
+ `,
63
+ }),
64
+ args: { disabled: false, icon: 'arrow-right' },
65
+ parameters: { docs: { source: { code: defaultTemplateSource } } },
66
+ };
67
+
68
+ /**
69
+ * Without a trailing icon — pass an empty `icon` to render the label only.
70
+ */
71
+ export const WithoutIcon: Story = {
72
+ render: (args) => ({
73
+ components: { CkHeaderActivityButton },
74
+ setup() {
75
+ return { args };
76
+ },
77
+ template: `
78
+ <CkHeaderActivityButton v-bind="args">
79
+ 3 sources
80
+ </CkHeaderActivityButton>
81
+ `,
82
+ }),
83
+ args: { icon: '' },
84
+ };
85
+
86
+ /**
87
+ * Disabled state: the button is non-interactive and does not emit `click`.
88
+ */
89
+ export const Disabled: Story = {
90
+ render: (args) => ({
91
+ components: { CkHeaderActivityButton },
92
+ setup() {
93
+ return { args };
94
+ },
95
+ template: `
96
+ <CkHeaderActivityButton v-bind="args">
97
+ 1 tool, 42 sources
98
+ </CkHeaderActivityButton>
99
+ `,
100
+ }),
101
+ args: { disabled: true },
102
+ };
@@ -0,0 +1,39 @@
1
+ <script setup lang="ts">
2
+ import type { CkHeaderActivityButtonProps } from './types';
3
+ import { UiButton, UiIcon } from '@aleph-alpha/ui-library';
4
+
5
+ defineOptions({
6
+ name: 'CkHeaderActivityButton',
7
+ });
8
+
9
+ const props = withDefaults(defineProps<CkHeaderActivityButtonProps>(), {
10
+ disabled: false,
11
+ icon: 'arrow-right',
12
+ });
13
+
14
+ const emit = defineEmits<{
15
+ click: [];
16
+ }>();
17
+
18
+ function handleClick() {
19
+ if (props.disabled) {
20
+ return;
21
+ }
22
+
23
+ emit('click');
24
+ }
25
+ </script>
26
+
27
+ <template>
28
+ <UiButton
29
+ v-bind="{ ariaLabel }"
30
+ :disabled="disabled"
31
+ variant="ghost"
32
+ size="sm"
33
+ class="label-12-medium text-content-on-surface-muted gap-1 rounded-[3px] px-2 py-1"
34
+ @click="handleClick"
35
+ >
36
+ <slot />
37
+ <UiIcon v-if="icon" :name="icon" :size="12" aria-hidden="true" />
38
+ </UiButton>
39
+ </template>
@@ -0,0 +1,2 @@
1
+ export { default as CkHeaderActivityButton } from './CkHeaderActivityButton.vue';
2
+ export type * from './types';
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Compact, ghost-styled button shown on the right of an agent message header
3
+ * (see `CkAgentMessageHeader`). Renders a short label followed by a trailing
4
+ * icon (an arrow by default) and emits a generic `click` event — it performs
5
+ * no action of its own, leaving the consumer to decide what happens (e.g. open
6
+ * an activity / chain-of-thought panel).
7
+ *
8
+ * @category Actions
9
+ * @useCases agent message header action, open activity panel, chain-of-thought summary toggle, tool/source indicator
10
+ * @keywords header, activity, action, button, ghost, arrow, agent, message
11
+ * @related CkAgentMessageHeader, CkAgentMessage, CkConversation, CkMessageButton
12
+ */
13
+ export interface CkHeaderActivityButtonProps {
14
+ /**
15
+ * Whether the button is disabled and ignores clicks.
16
+ * @default false
17
+ */
18
+ disabled?: boolean;
19
+ /**
20
+ * Accessible label for the button. Defaults to the rendered label text when
21
+ * omitted, but should be set when the slot content is not descriptive on its
22
+ * own.
23
+ */
24
+ ariaLabel?: string;
25
+ /**
26
+ * Trailing [Lucide](https://lucide.dev/icons) icon name shown after the
27
+ * label. Set to an empty string to hide the icon.
28
+ * @default 'arrow-right'
29
+ */
30
+ icon?: string;
31
+ }
32
+
33
+ export interface CkHeaderActivityButtonEmits {
34
+ /** Emitted when the button is pressed and not disabled. */
35
+ (e: 'click'): void;
36
+ }
@@ -199,6 +199,94 @@ describe('CkPromptInput', () => {
199
199
  });
200
200
  });
201
201
 
202
+ // The focus highlight (border + ring) must appear for keyboard navigation
203
+ // only — not when the textarea is focused by a mouse click. The component
204
+ // distinguishes the two by remembering whether a pointerdown immediately
205
+ // preceded the focus event.
206
+ describe('keyboard vs mouse focus highlight', () => {
207
+ const HIGHLIGHT_CLASS = 'ring-2';
208
+
209
+ it('shows the highlight when focused via keyboard (no preceding pointer)', async () => {
210
+ const { container } = render(CkPromptInput);
211
+
212
+ await fireEvent.focus(screen.getByRole('textbox'));
213
+
214
+ const form = container.querySelector('form');
215
+ expect(form?.className).toContain(HIGHLIGHT_CLASS);
216
+ expect(form?.className).toContain('border-border-surface-focus-ring');
217
+ });
218
+
219
+ it('does NOT show the highlight when focused via mouse click', async () => {
220
+ const { container } = render(CkPromptInput);
221
+ const textarea = screen.getByRole('textbox');
222
+
223
+ // A real mouse click fires pointerdown before focus.
224
+ await fireEvent.pointerDown(textarea);
225
+ await fireEvent.focus(textarea);
226
+
227
+ expect(container.querySelector('form')?.className).not.toContain(
228
+ HIGHLIGHT_CLASS,
229
+ );
230
+ });
231
+
232
+ it('removes the highlight on blur', async () => {
233
+ const { container } = render(CkPromptInput);
234
+ const textarea = screen.getByRole('textbox');
235
+
236
+ await fireEvent.focus(textarea);
237
+ expect(container.querySelector('form')?.className).toContain(
238
+ HIGHLIGHT_CLASS,
239
+ );
240
+
241
+ await fireEvent.blur(textarea);
242
+ expect(container.querySelector('form')?.className).not.toContain(
243
+ HIGHLIGHT_CLASS,
244
+ );
245
+ });
246
+
247
+ it('shows the highlight on a later keyboard focus after a prior mouse focus', async () => {
248
+ const { container } = render(CkPromptInput);
249
+ const textarea = screen.getByRole('textbox');
250
+
251
+ // First interaction: mouse click → no highlight.
252
+ await fireEvent.pointerDown(textarea);
253
+ await fireEvent.focus(textarea);
254
+ expect(container.querySelector('form')?.className).not.toContain(
255
+ HIGHLIGHT_CLASS,
256
+ );
257
+ await fireEvent.blur(textarea);
258
+
259
+ // Second interaction: tab in (focus with no preceding pointerdown). The
260
+ // pointer flag must have reset, so the highlight returns.
261
+ await fireEvent.focus(textarea);
262
+ expect(container.querySelector('form')?.className).toContain(
263
+ HIGHLIGHT_CLASS,
264
+ );
265
+ });
266
+
267
+ it('is not misclassified by a pointerdown inside an already-focused field', async () => {
268
+ const { container } = render(CkPromptInput);
269
+ const textarea = screen.getByRole('textbox');
270
+
271
+ // Keyboard focus → highlight on.
272
+ await fireEvent.focus(textarea);
273
+ expect(container.querySelector('form')?.className).toContain(
274
+ HIGHLIGHT_CLASS,
275
+ );
276
+
277
+ // Click inside the already-focused field: pointerdown fires with no
278
+ // following focus, which would otherwise leave the pointer flag stale.
279
+ await fireEvent.pointerDown(textarea);
280
+
281
+ // Blur then keyboard-focus again — the stale flag must not suppress it.
282
+ await fireEvent.blur(textarea);
283
+ await fireEvent.focus(textarea);
284
+ expect(container.querySelector('form')?.className).toContain(
285
+ HIGHLIGHT_CLASS,
286
+ );
287
+ });
288
+ });
289
+
202
290
  // The form switches between compact (single-row, rounded-3xl) and stacked
203
291
  // (multi-row, rounded-2xl) layouts based on (a) the presence of the
204
292
  // `toolbar-top` slot and (b) whether the textarea content exceeds one line.