@aleph-alpha/chat-kit 6.6.0 → 6.7.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 (65) hide show
  1. package/dist/{CkHistory.vue_vue_type_script_setup_true_lang-BnUMeeto.js → CkHistory.vue_vue_type_script_setup_true_lang-DF5yUf7K.js} +605 -145
  2. package/dist/chat-kit.css +1 -1
  3. package/dist/components/base/CkCodeEditor/CkCodeEditor.vue.d.ts +4 -4
  4. package/dist/components/base/CkMilkdownEditor/CkMilkdownEditor.vue.d.ts +1 -1
  5. package/dist/components/base/CkToolApproval/ApprovalCard.vue.d.ts +21 -0
  6. package/dist/components/base/CkToolApproval/ApprovalCard.vue.d.ts.map +1 -0
  7. package/dist/components/base/CkToolApproval/ArgumentsJson.vue.d.ts +10 -0
  8. package/dist/components/base/CkToolApproval/ArgumentsJson.vue.d.ts.map +1 -0
  9. package/dist/components/base/CkToolApproval/BodyBoundary.vue.d.ts +15 -0
  10. package/dist/components/base/CkToolApproval/BodyBoundary.vue.d.ts.map +1 -0
  11. package/dist/components/base/CkToolApproval/CardHeading.vue.d.ts +22 -0
  12. package/dist/components/base/CkToolApproval/CardHeading.vue.d.ts.map +1 -0
  13. package/dist/components/base/CkToolApproval/CkToolApproval.stories.d.ts +80 -0
  14. package/dist/components/base/CkToolApproval/CkToolApproval.stories.d.ts.map +1 -0
  15. package/dist/components/base/CkToolApproval/CkToolApproval.vue.d.ts +18 -0
  16. package/dist/components/base/CkToolApproval/CkToolApproval.vue.d.ts.map +1 -0
  17. package/dist/components/base/CkToolApproval/ResultRow.vue.d.ts +20 -0
  18. package/dist/components/base/CkToolApproval/ResultRow.vue.d.ts.map +1 -0
  19. package/dist/components/base/CkToolApproval/badge.d.ts +3 -0
  20. package/dist/components/base/CkToolApproval/badge.d.ts.map +1 -0
  21. package/dist/components/base/CkToolApproval/index.d.ts +3 -0
  22. package/dist/components/base/CkToolApproval/index.d.ts.map +1 -0
  23. package/dist/components/base/CkToolApproval/outcomes.d.ts +28 -0
  24. package/dist/components/base/CkToolApproval/outcomes.d.ts.map +1 -0
  25. package/dist/components/base/CkToolApproval/serializeArguments.d.ts +13 -0
  26. package/dist/components/base/CkToolApproval/serializeArguments.d.ts.map +1 -0
  27. package/dist/components/base/CkToolApproval/toolTitle.d.ts +7 -0
  28. package/dist/components/base/CkToolApproval/toolTitle.d.ts.map +1 -0
  29. package/dist/components/base/CkToolApproval/types.d.ts +102 -0
  30. package/dist/components/base/CkToolApproval/types.d.ts.map +1 -0
  31. package/dist/components/base/index.d.ts +2 -0
  32. package/dist/components/base/index.d.ts.map +1 -1
  33. package/dist/components/index.d.ts +2 -2
  34. package/dist/components/index.d.ts.map +1 -1
  35. package/dist/components/index.js +4 -3
  36. package/dist/{createCrepeEditor-BGSlwmKn.js → createCrepeEditor-DSfByw4o.js} +1 -1
  37. package/dist/handlers/index.js +1 -1
  38. package/dist/handlers/useChatKitLabels.d.ts +39 -0
  39. package/dist/handlers/useChatKitLabels.d.ts.map +1 -1
  40. package/dist/index.d.ts +2 -2
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +6 -5
  43. package/dist/{useChatKitLabels-DxW0N5U-.js → useChatKitLabels-ky3JI9gf.js} +13 -0
  44. package/package.json +2 -2
  45. package/src/components/base/CkToolApproval/ApprovalCard.vue +30 -0
  46. package/src/components/base/CkToolApproval/ArgumentsJson.vue +48 -0
  47. package/src/components/base/CkToolApproval/BodyBoundary.vue +26 -0
  48. package/src/components/base/CkToolApproval/CardHeading.vue +47 -0
  49. package/src/components/base/CkToolApproval/CkToolApproval.spec.ts +680 -0
  50. package/src/components/base/CkToolApproval/CkToolApproval.stories.ts +661 -0
  51. package/src/components/base/CkToolApproval/CkToolApproval.vue +240 -0
  52. package/src/components/base/CkToolApproval/ResultRow.vue +52 -0
  53. package/src/components/base/CkToolApproval/badge.ts +2 -0
  54. package/src/components/base/CkToolApproval/exports.spec.ts +22 -0
  55. package/src/components/base/CkToolApproval/index.ts +2 -0
  56. package/src/components/base/CkToolApproval/outcomes.ts +95 -0
  57. package/src/components/base/CkToolApproval/serializeArguments.ts +18 -0
  58. package/src/components/base/CkToolApproval/toolTitle.spec.ts +23 -0
  59. package/src/components/base/CkToolApproval/toolTitle.ts +19 -0
  60. package/src/components/base/CkToolApproval/types.ts +118 -0
  61. package/src/components/base/index.ts +11 -0
  62. package/src/components/index.ts +9 -0
  63. package/src/handlers/useChatKitLabels.ts +39 -0
  64. package/src/index.ts +9 -0
  65. package/src/test-empty.css +1 -0
@@ -0,0 +1,680 @@
1
+ import CkToolApproval from './CkToolApproval.vue';
2
+ import { provideChatKitLabels } from '../../../handlers/useChatKitLabels';
3
+ import type { CkToolApprovalRequest, CkToolApprovalStatus } from './types';
4
+ import userEvent from '@testing-library/user-event';
5
+ import { render, screen, within } from '@testing-library/vue';
6
+ import {
7
+ defineComponent,
8
+ h,
9
+ nextTick,
10
+ reactive,
11
+ ref,
12
+ type PropType,
13
+ } from 'vue';
14
+
15
+ const REQUEST_ID = 'mcpr_68d3a1f0c2b4';
16
+ const GROUP_NAME = 'Update work items Polarion';
17
+
18
+ const request: CkToolApprovalRequest = {
19
+ id: REQUEST_ID,
20
+ connector: 'polarion',
21
+ connectorLabel: 'Polarion',
22
+ toolName: 'update_work_items',
23
+ arguments: {
24
+ project_id: 'MCPT',
25
+ items: [{ work_item_id: 'MCPT-042', priority: '70.0' }],
26
+ },
27
+ };
28
+
29
+ const gmailRequest: CkToolApprovalRequest = {
30
+ id: 'mcpr_9f2e7c1d0a3b',
31
+ connector: 'gmail',
32
+ connectorLabel: 'Gmail',
33
+ connectorIcon: 'mail',
34
+ toolName: 'send_message',
35
+ toolTitle: 'Send email',
36
+ arguments: { to: ['team@example.com'] },
37
+ };
38
+
39
+ const WorkItemsBody = defineComponent({
40
+ props: {
41
+ request: {
42
+ type: Object as PropType<CkToolApprovalRequest>,
43
+ required: true,
44
+ },
45
+ },
46
+ setup(props) {
47
+ return () => h('p', `Custom body for ${props.request.toolName}`);
48
+ },
49
+ });
50
+
51
+ function card() {
52
+ return within(screen.getByRole('group', { name: GROUP_NAME }));
53
+ }
54
+
55
+ function allowButton() {
56
+ return screen.getByRole('button', { name: 'Allow' });
57
+ }
58
+
59
+ function denyButton() {
60
+ return screen.getByRole('button', { name: 'Deny' });
61
+ }
62
+
63
+ function circularArguments(): Record<string, unknown> {
64
+ const args: Record<string, unknown> = { project_id: 'MCPT' };
65
+ args.self = args;
66
+ return args;
67
+ }
68
+
69
+ describe('CkToolApproval', () => {
70
+ describe('pending', () => {
71
+ it('shows the action, the connector and the arguments as JSON', () => {
72
+ render(CkToolApproval, { props: { request, status: 'pending' } });
73
+
74
+ expect(card().getByText('Update work items')).toBeInTheDocument();
75
+ expect(card().getByText('Polarion')).toBeInTheDocument();
76
+ expect(
77
+ screen.getByRole('group', { name: GROUP_NAME }).textContent,
78
+ ).toContain('"work_item_id": "MCPT-042"');
79
+ });
80
+
81
+ it('does not repeat the raw tool name once the header names the action', () => {
82
+ render(CkToolApproval, { props: { request, status: 'pending' } });
83
+
84
+ expect(screen.queryByText('update_work_items')).not.toBeInTheDocument();
85
+ });
86
+
87
+ it("prefers the request's toolTitle over the derived one", () => {
88
+ render(CkToolApproval, {
89
+ props: { request: gmailRequest, status: 'pending' },
90
+ });
91
+
92
+ expect(screen.getByText('Send email')).toBeInTheDocument();
93
+ expect(screen.queryByText('Send message')).not.toBeInTheDocument();
94
+ });
95
+
96
+ it('hides the connector chip when the request has no connectorLabel', () => {
97
+ render(CkToolApproval, {
98
+ props: {
99
+ request: { ...request, connectorLabel: undefined },
100
+ status: 'pending',
101
+ },
102
+ });
103
+
104
+ expect(screen.queryByText('Polarion')).not.toBeInTheDocument();
105
+ expect(screen.getByText('Update work items')).toBeInTheDocument();
106
+ });
107
+
108
+ it('exposes the card as a group named by its action and connector', () => {
109
+ render(CkToolApproval, { props: { request, status: 'pending' } });
110
+
111
+ expect(
112
+ screen.getByRole('group', { name: GROUP_NAME }),
113
+ ).toBeInTheDocument();
114
+ });
115
+
116
+ // Icons are decorative (aria-hidden), so they have no accessible handle;
117
+ // like CkCitationCard's spec, these check the class lucide renders.
118
+ it.each(['pending', 'denied'] as const)(
119
+ 'puts a class from the host on the %s card',
120
+ (status) => {
121
+ render(CkToolApproval, {
122
+ props: { request, status },
123
+ attrs: { class: 'mt-4' },
124
+ });
125
+
126
+ expect(screen.getByRole('group', { name: GROUP_NAME })).toHaveClass(
127
+ 'mt-4',
128
+ );
129
+ },
130
+ );
131
+
132
+ it('shows the default app-window icon in the connector chip', () => {
133
+ const { container } = render(CkToolApproval, {
134
+ props: { request, status: 'pending' },
135
+ });
136
+
137
+ expect(container.querySelector('.lucide-app-window')).toBeInTheDocument();
138
+ });
139
+
140
+ it("uses the request's connectorIcon in the chip", () => {
141
+ const { container } = render(CkToolApproval, {
142
+ props: { request: gmailRequest, status: 'pending' },
143
+ });
144
+
145
+ expect(container.querySelector('.lucide-mail')).toBeInTheDocument();
146
+ expect(
147
+ container.querySelector('.lucide-app-window'),
148
+ ).not.toBeInTheDocument();
149
+ });
150
+
151
+ it('lets the connector-icon slot replace the chip icon with a logo', () => {
152
+ render(CkToolApproval, {
153
+ props: { request, status: 'pending' },
154
+ slots: {
155
+ 'connector-icon': () => h('img', { alt: 'Polarion logo' }),
156
+ },
157
+ });
158
+
159
+ expect(screen.getByAltText('Polarion logo')).toBeInTheDocument();
160
+ });
161
+
162
+ it('emits allow with the request id when Allow is clicked', async () => {
163
+ const user = userEvent.setup();
164
+ const { emitted } = render(CkToolApproval, {
165
+ props: { request, status: 'pending' },
166
+ });
167
+
168
+ await user.click(allowButton());
169
+
170
+ expect(emitted('allow')).toEqual([[REQUEST_ID]]);
171
+ expect(emitted('deny')).toBeUndefined();
172
+ });
173
+
174
+ it('emits deny with the request id when Deny is clicked', async () => {
175
+ const user = userEvent.setup();
176
+ const { emitted } = render(CkToolApproval, {
177
+ props: { request, status: 'pending' },
178
+ });
179
+
180
+ await user.click(denyButton());
181
+
182
+ expect(emitted('deny')).toEqual([[REQUEST_ID]]);
183
+ expect(emitted('allow')).toBeUndefined();
184
+ });
185
+
186
+ it('renders the custom body registered for the connector and tool instead of JSON', () => {
187
+ render(CkToolApproval, {
188
+ props: {
189
+ request,
190
+ status: 'pending',
191
+ bodyComponents: { polarion: { update_work_items: WorkItemsBody } },
192
+ },
193
+ });
194
+
195
+ expect(
196
+ card().getByText('Custom body for update_work_items'),
197
+ ).toBeInTheDocument();
198
+ expect(
199
+ screen.getByRole('group', { name: GROUP_NAME }).textContent,
200
+ ).not.toContain('"work_item_id"');
201
+ });
202
+
203
+ it('falls back to JSON when only a different tool of the connector is registered', () => {
204
+ render(CkToolApproval, {
205
+ props: {
206
+ request,
207
+ status: 'pending',
208
+ bodyComponents: { polarion: { create_document: WorkItemsBody } },
209
+ },
210
+ });
211
+
212
+ expect(screen.queryByText(/Custom body/)).not.toBeInTheDocument();
213
+ expect(
214
+ screen.getByRole('group', { name: GROUP_NAME }).textContent,
215
+ ).toContain('"work_item_id": "MCPT-042"');
216
+ });
217
+
218
+ it('renders a body map passed in reactive state without a Vue warning', () => {
219
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
220
+ const bodies = reactive({
221
+ polarion: { update_work_items: WorkItemsBody },
222
+ });
223
+
224
+ render(CkToolApproval, {
225
+ props: { request, status: 'pending', bodyComponents: bodies },
226
+ });
227
+
228
+ expect(
229
+ screen.getByText('Custom body for update_work_items'),
230
+ ).toBeInTheDocument();
231
+ expect(warn).not.toHaveBeenCalled();
232
+ warn.mockRestore();
233
+ });
234
+ });
235
+
236
+ describe('robustness', () => {
237
+ it('falls back to JSON when a custom body crashes, so nothing is approved blind', async () => {
238
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
239
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
240
+ const BrokenBody = defineComponent({
241
+ props: {
242
+ request: {
243
+ type: Object as PropType<CkToolApprovalRequest>,
244
+ required: true,
245
+ },
246
+ },
247
+ setup() {
248
+ return () => {
249
+ throw new Error('unexpected arguments');
250
+ };
251
+ },
252
+ });
253
+
254
+ render(CkToolApproval, {
255
+ props: {
256
+ request,
257
+ status: 'pending',
258
+ bodyComponents: { polarion: { update_work_items: BrokenBody } },
259
+ },
260
+ });
261
+ await nextTick();
262
+
263
+ expect(
264
+ screen.getByRole('group', { name: GROUP_NAME }).textContent,
265
+ ).toContain('"work_item_id": "MCPT-042"');
266
+ expect(error).toHaveBeenCalledWith(
267
+ expect.stringContaining('[CkToolApproval]'),
268
+ expect.any(Error),
269
+ );
270
+ error.mockRestore();
271
+ warn.mockRestore();
272
+ });
273
+
274
+ it('ignores built-in object keys when looking up a custom body', () => {
275
+ render(CkToolApproval, {
276
+ props: {
277
+ request: { ...request, toolName: 'constructor' },
278
+ status: 'pending',
279
+ bodyComponents: { polarion: { update_work_items: WorkItemsBody } },
280
+ },
281
+ });
282
+
283
+ expect(
284
+ screen.getByRole('group', { name: 'Constructor Polarion' }).textContent,
285
+ ).toContain('"project_id": "MCPT"');
286
+ });
287
+
288
+ it('shortens very large arguments and says so', () => {
289
+ render(CkToolApproval, {
290
+ props: {
291
+ request: { ...request, arguments: { file: 'x'.repeat(100_000) } },
292
+ status: 'pending',
293
+ },
294
+ });
295
+
296
+ const text = screen.getByRole('group', { name: GROUP_NAME }).textContent;
297
+ expect(text?.length).toBeLessThan(30_000);
298
+ expect(
299
+ screen.getByText('Arguments shortened for display'),
300
+ ).toBeInTheDocument();
301
+ });
302
+
303
+ it.each([
304
+ ['a bigint', { count: BigInt(1) }],
305
+ ['a circular reference', circularArguments()],
306
+ ])('keeps Allow off when arguments with %s cannot be shown', (_, args) => {
307
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
308
+
309
+ render(CkToolApproval, {
310
+ props: { request: { ...request, arguments: args }, status: 'pending' },
311
+ });
312
+
313
+ expect(
314
+ card().getByText("These arguments can't be shown"),
315
+ ).toBeInTheDocument();
316
+ expect(allowButton()).toBeDisabled();
317
+ expect(denyButton()).toBeEnabled();
318
+ expect(error).toHaveBeenCalledWith(
319
+ expect.stringContaining('[CkToolApproval]'),
320
+ expect.any(TypeError),
321
+ );
322
+ error.mockRestore();
323
+ });
324
+
325
+ it('treats an unknown status from a JavaScript host as failed instead of crashing', () => {
326
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
327
+
328
+ render(CkToolApproval, {
329
+ props: {
330
+ request,
331
+ status: 'expired' as unknown as CkToolApprovalStatus,
332
+ },
333
+ });
334
+
335
+ expect(card().getByText('Failed')).toBeInTheDocument();
336
+ expect(warn).toHaveBeenCalled();
337
+ warn.mockRestore();
338
+ });
339
+
340
+ it('keeps a logo from the connector-icon slot out of the card name', () => {
341
+ render(CkToolApproval, {
342
+ props: { request, status: 'pending' },
343
+ slots: {
344
+ 'connector-icon': () => h('img', { alt: 'Polarion logo' }),
345
+ },
346
+ });
347
+
348
+ expect(
349
+ screen.getByRole('group', { name: GROUP_NAME }),
350
+ ).toBeInTheDocument();
351
+ });
352
+ });
353
+
354
+ describe('button lock', () => {
355
+ it('locks both buttons after the first click so only one answer is sent', async () => {
356
+ const user = userEvent.setup();
357
+ const { emitted } = render(CkToolApproval, {
358
+ props: { request, status: 'pending' },
359
+ });
360
+
361
+ await user.click(allowButton());
362
+ await user.click(allowButton());
363
+ await user.click(denyButton());
364
+
365
+ expect(allowButton()).toBeDisabled();
366
+ expect(denyButton()).toBeDisabled();
367
+ expect(emitted('allow')).toEqual([[REQUEST_ID]]);
368
+ expect(emitted('deny')).toBeUndefined();
369
+ });
370
+
371
+ it('unlocks for a new request', async () => {
372
+ const user = userEvent.setup();
373
+ const { rerender } = render(CkToolApproval, {
374
+ props: { request, status: 'pending' },
375
+ });
376
+ await user.click(allowButton());
377
+
378
+ await rerender({
379
+ request: { ...request, id: 'mcpr_next' },
380
+ status: 'pending',
381
+ });
382
+
383
+ expect(allowButton()).toBeEnabled();
384
+ expect(denyButton()).toBeEnabled();
385
+ });
386
+
387
+ it('stays locked when the host passes a fresh copy of the same request', async () => {
388
+ const user = userEvent.setup();
389
+ const { rerender } = render(CkToolApproval, {
390
+ props: { request, status: 'pending' },
391
+ });
392
+ await user.click(allowButton());
393
+
394
+ await rerender({ request: { ...request }, status: 'pending' });
395
+
396
+ expect(allowButton()).toBeDisabled();
397
+ expect(denyButton()).toBeDisabled();
398
+ });
399
+
400
+ it('unlocks when the same request comes back to pending', async () => {
401
+ const user = userEvent.setup();
402
+ const { rerender } = render(CkToolApproval, {
403
+ props: { request, status: 'pending' },
404
+ });
405
+ await user.click(allowButton());
406
+
407
+ await rerender({ request, status: 'failed' });
408
+ await rerender({ request, status: 'pending' });
409
+
410
+ expect(allowButton()).toBeEnabled();
411
+ });
412
+
413
+ it('lets the host unlock after a failed send so the user can retry', async () => {
414
+ const user = userEvent.setup();
415
+ const approval = ref<InstanceType<typeof CkToolApproval>>();
416
+ const Host = defineComponent({
417
+ setup() {
418
+ return () =>
419
+ h(CkToolApproval, { ref: approval, request, status: 'pending' });
420
+ },
421
+ });
422
+ render(Host);
423
+ await user.click(allowButton());
424
+ expect(allowButton()).toBeDisabled();
425
+
426
+ approval.value?.unlock();
427
+ await Promise.resolve();
428
+
429
+ expect(allowButton()).toBeEnabled();
430
+ });
431
+ });
432
+
433
+ describe('decided', () => {
434
+ it('collapses an allowed request to one row with the summary and an Allowed badge', () => {
435
+ render(CkToolApproval, {
436
+ props: { request, status: 'allowed', summary: '2 work items updated' },
437
+ });
438
+
439
+ expect(
440
+ card().getByText('Approved by you · 2 work items updated'),
441
+ ).toBeInTheDocument();
442
+ expect(card().getByText('Allowed')).toBeInTheDocument();
443
+ expect(card().getByText('Update work items')).toBeInTheDocument();
444
+ expect(card().getByText('Polarion')).toBeInTheDocument();
445
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
446
+ expect(
447
+ screen.getByRole('group', { name: GROUP_NAME }).textContent,
448
+ ).not.toContain('"work_item_id"');
449
+ });
450
+
451
+ it('keeps the toolTitle and connector icon in the result row', () => {
452
+ const { container } = render(CkToolApproval, {
453
+ props: { request: gmailRequest, status: 'allowed' },
454
+ });
455
+
456
+ expect(screen.getByText('Send email')).toBeInTheDocument();
457
+ expect(container.querySelector('.lucide-mail')).toBeInTheDocument();
458
+ });
459
+
460
+ it('keeps the decided row a named group described by its result', () => {
461
+ render(CkToolApproval, { props: { request, status: 'denied' } });
462
+
463
+ const group = screen.getByRole('group', { name: GROUP_NAME });
464
+ expect(group).toHaveAccessibleDescription(
465
+ 'Denied by you · Nothing was updated',
466
+ );
467
+ });
468
+
469
+ it('shows only the outcome when an allowed request has no summary', () => {
470
+ render(CkToolApproval, { props: { request, status: 'allowed' } });
471
+
472
+ expect(screen.getByText('Approved by you')).toBeInTheDocument();
473
+ });
474
+
475
+ it.each([
476
+ ['denied', 'Denied by you · Nothing was updated', 'Denied'],
477
+ ['failed', 'Failed · Nothing was updated', 'Failed'],
478
+ ['skipped', 'Skipped · Nothing was updated', 'Skipped'],
479
+ ] as const)(
480
+ 'shows a %s request with its result line and badge',
481
+ (status, line, badge) => {
482
+ render(CkToolApproval, { props: { request, status } });
483
+
484
+ expect(card().getByText(line)).toBeInTheDocument();
485
+ expect(card().getByText(badge)).toBeInTheDocument();
486
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
487
+ },
488
+ );
489
+
490
+ it('marks a skipped request with the skip-forward icon', () => {
491
+ const { container } = render(CkToolApproval, {
492
+ props: { request, status: 'skipped' },
493
+ });
494
+
495
+ expect(
496
+ container.querySelector('.lucide-skip-forward'),
497
+ ).toBeInTheDocument();
498
+ });
499
+
500
+ it.each([
501
+ ['denied', 'Denied by you · Nothing was sent'],
502
+ ['failed', 'Failed · Nothing was sent'],
503
+ ['skipped', 'Skipped · Nothing was sent'],
504
+ ] as const)(
505
+ 'uses the host summary as the detail of a %s request',
506
+ (status, line) => {
507
+ render(CkToolApproval, {
508
+ props: { request, status, summary: 'Nothing was sent' },
509
+ });
510
+
511
+ expect(screen.getByText(line)).toBeInTheDocument();
512
+ },
513
+ );
514
+
515
+ it.each(['denied', 'failed', 'skipped'] as const)(
516
+ 'shows no detail on a %s request when the host passes an empty summary',
517
+ (status) => {
518
+ render(CkToolApproval, { props: { request, status, summary: '' } });
519
+
520
+ expect(
521
+ screen.getByRole('group', { name: GROUP_NAME }),
522
+ ).not.toHaveTextContent('Nothing was updated');
523
+ },
524
+ );
525
+ });
526
+
527
+ describe('from pending to decided', () => {
528
+ function hostWithStatus() {
529
+ const status = ref<CkToolApprovalStatus>('pending');
530
+ const Host = defineComponent({
531
+ setup() {
532
+ return () =>
533
+ h(CkToolApproval, {
534
+ request,
535
+ status: status.value,
536
+ summary: '2 work items updated',
537
+ onAllow: () => {
538
+ status.value = 'allowed';
539
+ },
540
+ });
541
+ },
542
+ });
543
+ return { Host, status };
544
+ }
545
+
546
+ it('collapses the same card to the result row once the host decides', async () => {
547
+ const user = userEvent.setup();
548
+ const { Host } = hostWithStatus();
549
+ render(Host);
550
+
551
+ await user.click(allowButton());
552
+
553
+ expect(
554
+ card().getByText('Approved by you · 2 work items updated'),
555
+ ).toBeInTheDocument();
556
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
557
+ });
558
+
559
+ it('moves focus to the result row when the clicked button disappears', async () => {
560
+ const user = userEvent.setup();
561
+ const { Host } = hostWithStatus();
562
+ render(Host);
563
+
564
+ await user.click(allowButton());
565
+
566
+ expect(screen.getByRole('group', { name: GROUP_NAME })).toHaveFocus();
567
+ });
568
+
569
+ it('keeps focus in the card while the decision is being sent', async () => {
570
+ const user = userEvent.setup();
571
+ render(CkToolApproval, { props: { request, status: 'pending' } });
572
+
573
+ await user.click(allowButton());
574
+
575
+ expect(screen.getByRole('group', { name: GROUP_NAME })).toHaveFocus();
576
+ });
577
+
578
+ it('announces the outcome when focus was elsewhere', async () => {
579
+ const { Host, status } = hostWithStatus();
580
+ const { container } = render(Host);
581
+ const announcer = container.querySelector('[aria-live="polite"]');
582
+ expect(announcer).toHaveTextContent('');
583
+
584
+ status.value = 'denied';
585
+ await nextTick();
586
+
587
+ expect(announcer).toHaveTextContent(
588
+ 'Denied by you · 2 work items updated',
589
+ );
590
+ });
591
+
592
+ it('does not announce twice when focus moves to the result', async () => {
593
+ const user = userEvent.setup();
594
+ const { Host } = hostWithStatus();
595
+ const { container } = render(Host);
596
+
597
+ await user.click(allowButton());
598
+
599
+ expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent(
600
+ '',
601
+ );
602
+ });
603
+
604
+ it('does not take focus when the card was not focused', async () => {
605
+ const { Host, status } = hostWithStatus();
606
+ render(Host);
607
+
608
+ status.value = 'allowed';
609
+ await Promise.resolve();
610
+ await Promise.resolve();
611
+
612
+ expect(document.body).toHaveFocus();
613
+ });
614
+ });
615
+
616
+ describe('labels', () => {
617
+ it('labels the copy button of the JSON block', () => {
618
+ const Host = defineComponent({
619
+ setup() {
620
+ provideChatKitLabels(() => ({ codeEditorCopy: 'Code kopieren' }));
621
+ return () => h(CkToolApproval, { request, status: 'pending' });
622
+ },
623
+ });
624
+
625
+ render(Host);
626
+
627
+ expect(
628
+ screen.getByRole('button', { name: 'Code kopieren' }),
629
+ ).toBeInTheDocument();
630
+ });
631
+
632
+ it('keeps the English default when a label is explicitly undefined', () => {
633
+ const Host = defineComponent({
634
+ setup() {
635
+ provideChatKitLabels(() => ({ toolApprovalAllow: undefined }));
636
+ return () => h(CkToolApproval, { request, status: 'pending' });
637
+ },
638
+ });
639
+
640
+ render(Host);
641
+
642
+ expect(allowButton()).toBeInTheDocument();
643
+ });
644
+
645
+ it('reads every visible text from the provided labels', () => {
646
+ const Host = defineComponent({
647
+ props: { status: { type: String, required: true } },
648
+ setup(props) {
649
+ provideChatKitLabels(() => ({
650
+ toolApprovalAllow: 'Erlauben',
651
+ toolApprovalDeny: 'Ablehnen',
652
+ toolApprovalDeniedResult: 'Von dir abgelehnt',
653
+ toolApprovalNothingChanged: 'Nichts wurde geändert',
654
+ toolApprovalDeniedBadge: 'Abgelehnt',
655
+ }));
656
+ return () =>
657
+ h(CkToolApproval, {
658
+ request,
659
+ status: props.status as CkToolApprovalStatus,
660
+ });
661
+ },
662
+ });
663
+
664
+ const { rerender } = render(Host, { props: { status: 'pending' } });
665
+ expect(
666
+ screen.getByRole('button', { name: 'Erlauben' }),
667
+ ).toBeInTheDocument();
668
+ expect(
669
+ screen.getByRole('button', { name: 'Ablehnen' }),
670
+ ).toBeInTheDocument();
671
+
672
+ return rerender({ status: 'denied' }).then(() => {
673
+ expect(
674
+ card().getByText('Von dir abgelehnt · Nichts wurde geändert'),
675
+ ).toBeInTheDocument();
676
+ expect(screen.getByText('Abgelehnt')).toBeInTheDocument();
677
+ });
678
+ });
679
+ });
680
+ });