@flamingo-stack/openframe-frontend-core 0.0.583 → 0.0.584-1973.5420.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 (149) hide show
  1. package/dist/chat-protocol/events.d.ts +8 -2
  2. package/dist/chat-protocol/events.d.ts.map +1 -1
  3. package/dist/chat-protocol/index.cjs +177 -0
  4. package/dist/chat-protocol/index.cjs.map +1 -1
  5. package/dist/chat-protocol/index.js +177 -0
  6. package/dist/chat-protocol/index.js.map +1 -1
  7. package/dist/chat-protocol/nats-decoder.d.ts.map +1 -1
  8. package/dist/chat-protocol/source-metadata.d.ts +3 -0
  9. package/dist/chat-protocol/source-metadata.d.ts.map +1 -0
  10. package/dist/{chunk-EATPU5EF.cjs → chunk-3MAG3EG4.cjs} +9 -9
  11. package/dist/{chunk-EATPU5EF.cjs.map → chunk-3MAG3EG4.cjs.map} +1 -1
  12. package/dist/{chunk-IKUSKKFZ.cjs → chunk-4TZOFXPH.cjs} +712 -432
  13. package/dist/chunk-4TZOFXPH.cjs.map +1 -0
  14. package/dist/{chunk-WMRXB6RS.js → chunk-54WRMPCY.js} +3 -3
  15. package/dist/{chunk-WTC6QQNX.cjs → chunk-7IVT56YY.cjs} +3 -3
  16. package/dist/{chunk-WTC6QQNX.cjs.map → chunk-7IVT56YY.cjs.map} +1 -1
  17. package/dist/{chunk-PFKWJ6PJ.js → chunk-AQW2PDRS.js} +2 -2
  18. package/dist/{chunk-DROXHJVZ.js → chunk-AVVKXNGI.js} +2 -2
  19. package/dist/{chunk-YNLIXABT.cjs → chunk-CE6CA6EM.cjs} +43 -43
  20. package/dist/{chunk-YNLIXABT.cjs.map → chunk-CE6CA6EM.cjs.map} +1 -1
  21. package/dist/{chunk-TRD2YICZ.cjs → chunk-CGLQLBMQ.cjs} +21 -21
  22. package/dist/{chunk-TRD2YICZ.cjs.map → chunk-CGLQLBMQ.cjs.map} +1 -1
  23. package/dist/{chunk-XWBND4G5.js → chunk-CO4WRST6.js} +301 -21
  24. package/dist/chunk-CO4WRST6.js.map +1 -0
  25. package/dist/{chunk-IJVWKLJK.cjs → chunk-DHLD3JXN.cjs} +69 -69
  26. package/dist/{chunk-IJVWKLJK.cjs.map → chunk-DHLD3JXN.cjs.map} +1 -1
  27. package/dist/{chunk-M6ZUEBDK.cjs → chunk-F2EN6MXN.cjs} +159 -159
  28. package/dist/{chunk-M6ZUEBDK.cjs.map → chunk-F2EN6MXN.cjs.map} +1 -1
  29. package/dist/{chunk-J3EEL6YC.js → chunk-FH2FH337.js} +5 -5
  30. package/dist/{chunk-L26MD32B.js → chunk-GDHI3JN5.js} +3 -3
  31. package/dist/{chunk-VWHEXQ64.js → chunk-GFIH73Q3.js} +3 -3
  32. package/dist/{chunk-R5A3MGMY.js → chunk-H6GYY5CB.js} +3 -3
  33. package/dist/{chunk-FCLM2RXI.js → chunk-HGJAUYHW.js} +2 -2
  34. package/dist/{chunk-RBDVHLT5.cjs → chunk-HKKQCVVY.cjs} +22 -22
  35. package/dist/{chunk-RBDVHLT5.cjs.map → chunk-HKKQCVVY.cjs.map} +1 -1
  36. package/dist/{chunk-KDWNMFX4.cjs → chunk-JEMGFSC2.cjs} +24 -24
  37. package/dist/{chunk-KDWNMFX4.cjs.map → chunk-JEMGFSC2.cjs.map} +1 -1
  38. package/dist/{chunk-BTBW4L2W.cjs → chunk-JX6RSSZP.cjs} +33 -33
  39. package/dist/{chunk-BTBW4L2W.cjs.map → chunk-JX6RSSZP.cjs.map} +1 -1
  40. package/dist/{chunk-WHNC7C76.js → chunk-KN2ZCZQ7.js} +5 -5
  41. package/dist/{chunk-WBTKXNKZ.cjs → chunk-MYVHYVQM.cjs} +10 -10
  42. package/dist/{chunk-WBTKXNKZ.cjs.map → chunk-MYVHYVQM.cjs.map} +1 -1
  43. package/dist/{chunk-3TNM6SDP.cjs → chunk-PI3BNDCW.cjs} +15 -15
  44. package/dist/{chunk-3TNM6SDP.cjs.map → chunk-PI3BNDCW.cjs.map} +1 -1
  45. package/dist/{chunk-XZ3MKTJZ.js → chunk-PWI66GZF.js} +2 -2
  46. package/dist/{chunk-IFPGXSMZ.cjs → chunk-RTI3TYMX.cjs} +3 -3
  47. package/dist/{chunk-IFPGXSMZ.cjs.map → chunk-RTI3TYMX.cjs.map} +1 -1
  48. package/dist/{chunk-6BI3AYBY.cjs → chunk-SS2TCIKA.cjs} +4 -4
  49. package/dist/{chunk-6BI3AYBY.cjs.map → chunk-SS2TCIKA.cjs.map} +1 -1
  50. package/dist/{chunk-HIHPWVKP.js → chunk-T35JA5XV.js} +5 -5
  51. package/dist/{chunk-EZ5VU5WS.js → chunk-TLZ7HTXP.js} +2 -2
  52. package/dist/{chunk-OCXFFGFA.cjs → chunk-UZUG7QZ3.cjs} +10 -10
  53. package/dist/{chunk-OCXFFGFA.cjs.map → chunk-UZUG7QZ3.cjs.map} +1 -1
  54. package/dist/{chunk-3RR67XC2.cjs → chunk-W46U5J3H.cjs} +5 -5
  55. package/dist/{chunk-3RR67XC2.cjs.map → chunk-W46U5J3H.cjs.map} +1 -1
  56. package/dist/{chunk-IOP7BCRQ.js → chunk-WFF5JMQ2.js} +6 -6
  57. package/dist/{chunk-AJLF6MBD.js → chunk-X4HLHN36.js} +3 -3
  58. package/dist/{chunk-PMA7DVQR.js → chunk-ZWB64HOD.js} +4 -4
  59. package/dist/components/case-studies/index.cjs +9 -9
  60. package/dist/components/case-studies/index.js +3 -3
  61. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  62. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  63. package/dist/components/chat/entity-cards/dispatch.d.ts.map +1 -1
  64. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +4 -1
  65. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  66. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +2 -28
  67. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  68. package/dist/components/chat/index.cjs +2 -2
  69. package/dist/components/chat/index.js +1 -1
  70. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -1
  71. package/dist/components/chat/tool-execution-display.d.ts.map +1 -1
  72. package/dist/components/chat/types/component.types.d.ts +2 -0
  73. package/dist/components/chat/types/component.types.d.ts.map +1 -1
  74. package/dist/components/chat/types/message.types.d.ts +35 -1
  75. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  76. package/dist/components/chat/types/unified-chat-state.types.d.ts +3 -3
  77. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
  78. package/dist/components/chat/utils/chat-source-metadata.d.ts +5 -0
  79. package/dist/components/chat/utils/chat-source-metadata.d.ts.map +1 -0
  80. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  81. package/dist/components/contact/index.cjs +4 -4
  82. package/dist/components/contact/index.js +3 -3
  83. package/dist/components/docs/index.cjs +7 -7
  84. package/dist/components/docs/index.js +6 -6
  85. package/dist/components/embeds/index.cjs +4 -4
  86. package/dist/components/embeds/index.js +3 -3
  87. package/dist/components/faq/index.cjs +4 -4
  88. package/dist/components/faq/index.js +3 -3
  89. package/dist/components/features/index.cjs +3 -3
  90. package/dist/components/features/index.js +2 -2
  91. package/dist/components/help-center-pages/index.cjs +23 -23
  92. package/dist/components/help-center-pages/index.js +14 -14
  93. package/dist/components/index.cjs +36 -36
  94. package/dist/components/index.js +13 -13
  95. package/dist/components/meeting-scheduler/index.cjs +21 -21
  96. package/dist/components/meeting-scheduler/index.js +2 -2
  97. package/dist/components/navigation/index.cjs +6 -6
  98. package/dist/components/navigation/index.js +5 -5
  99. package/dist/components/onboarding-guides/index.cjs +6 -6
  100. package/dist/components/onboarding-guides/index.js +5 -5
  101. package/dist/components/tickets/index.cjs +7 -7
  102. package/dist/components/tickets/index.js +6 -6
  103. package/dist/components/ui/index.cjs +3 -3
  104. package/dist/components/ui/index.js +2 -2
  105. package/dist/index.cjs +7 -7
  106. package/dist/index.js +6 -6
  107. package/dist/utils/index.cjs.map +1 -1
  108. package/dist/utils/index.js.map +1 -1
  109. package/package.json +1 -1
  110. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +130 -0
  111. package/src/chat-protocol/events.ts +9 -1
  112. package/src/chat-protocol/nats-decoder.ts +7 -0
  113. package/src/chat-protocol/source-metadata.ts +187 -0
  114. package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +86 -0
  115. package/src/components/chat/__tests__/embeddable-chat-sources.test.tsx +171 -0
  116. package/src/components/chat/__tests__/tool-execution-display.test.tsx +93 -0
  117. package/src/components/chat/chat-message-enhanced.tsx +28 -13
  118. package/src/components/chat/chat-message-list.tsx +1 -0
  119. package/src/components/chat/embeddable-chat.tsx +10 -1
  120. package/src/components/chat/entity-cards/__tests__/dispatch-video-refs.test.tsx +74 -0
  121. package/src/components/chat/entity-cards/dispatch.tsx +48 -11
  122. package/src/components/chat/hooks/use-nats-chat-adapter.ts +7 -0
  123. package/src/components/chat/hooks/use-sse-chat-adapter.ts +2 -29
  124. package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +56 -0
  125. package/src/components/chat/stream/chat-stream-reducer.ts +31 -0
  126. package/src/components/chat/tool-execution-display.tsx +5 -2
  127. package/src/components/chat/types/component.types.ts +2 -0
  128. package/src/components/chat/types/message.types.ts +39 -0
  129. package/src/components/chat/types/unified-chat-state.types.ts +4 -3
  130. package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +57 -0
  131. package/src/components/chat/utils/chat-source-metadata.ts +19 -0
  132. package/src/components/chat/utils/process-historical-messages.ts +19 -1
  133. package/dist/chunk-IKUSKKFZ.cjs.map +0 -1
  134. package/dist/chunk-XWBND4G5.js.map +0 -1
  135. /package/dist/{chunk-WMRXB6RS.js.map → chunk-54WRMPCY.js.map} +0 -0
  136. /package/dist/{chunk-PFKWJ6PJ.js.map → chunk-AQW2PDRS.js.map} +0 -0
  137. /package/dist/{chunk-DROXHJVZ.js.map → chunk-AVVKXNGI.js.map} +0 -0
  138. /package/dist/{chunk-J3EEL6YC.js.map → chunk-FH2FH337.js.map} +0 -0
  139. /package/dist/{chunk-L26MD32B.js.map → chunk-GDHI3JN5.js.map} +0 -0
  140. /package/dist/{chunk-VWHEXQ64.js.map → chunk-GFIH73Q3.js.map} +0 -0
  141. /package/dist/{chunk-R5A3MGMY.js.map → chunk-H6GYY5CB.js.map} +0 -0
  142. /package/dist/{chunk-FCLM2RXI.js.map → chunk-HGJAUYHW.js.map} +0 -0
  143. /package/dist/{chunk-WHNC7C76.js.map → chunk-KN2ZCZQ7.js.map} +0 -0
  144. /package/dist/{chunk-XZ3MKTJZ.js.map → chunk-PWI66GZF.js.map} +0 -0
  145. /package/dist/{chunk-HIHPWVKP.js.map → chunk-T35JA5XV.js.map} +0 -0
  146. /package/dist/{chunk-EZ5VU5WS.js.map → chunk-TLZ7HTXP.js.map} +0 -0
  147. /package/dist/{chunk-IOP7BCRQ.js.map → chunk-WFF5JMQ2.js.map} +0 -0
  148. /package/dist/{chunk-AJLF6MBD.js.map → chunk-X4HLHN36.js.map} +0 -0
  149. /package/dist/{chunk-PMA7DVQR.js.map → chunk-ZWB64HOD.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.583",
3
+ "version": "0.0.584-1973.5420.1",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -329,3 +329,133 @@ describe('decodeNatsChunk — seq envelope', () => {
329
329
  }).toMatchSnapshot();
330
330
  });
331
331
  });
332
+
333
+ describe('decodeNatsChunk — Mingo source metadata', () => {
334
+ it.each(['GUIDE', 'SOURCES'] as const)('decodes %s payloads into safe sources and playable refs', type => {
335
+ expect(
336
+ decodeNatsChunk({
337
+ type,
338
+ streamSeq: 81,
339
+ payload: {
340
+ sources: [
341
+ {
342
+ index: 1,
343
+ name: 'Install agent',
344
+ path: 'docs/install.md',
345
+ documentType: 'markdown',
346
+ externalUrl: 'https://docs.example.test/install',
347
+ },
348
+ {
349
+ index: 2,
350
+ name: 'Unsafe source',
351
+ path: 'docs/unsafe.md',
352
+ documentType: 'markdown',
353
+ externalUrl: 'javascript:alert(1)',
354
+ },
355
+ {
356
+ index: 1,
357
+ name: 'Duplicate',
358
+ path: 'docs/duplicate.md',
359
+ documentType: 'markdown',
360
+ },
361
+ { index: 3, name: '', path: 'docs/empty.md', documentType: 'markdown' },
362
+ ],
363
+ videos: [
364
+ {
365
+ ref: '[card://video:MdFJNoJeqZQ]',
366
+ title: 'Install the agent',
367
+ url: 'https://www.youtube.com/watch?v=MdFJNoJeqZQ',
368
+ sourceRepo: 'embedded-videos',
369
+ metadata: { youtubeUrl: 'MdFJNoJeqZQ' },
370
+ },
371
+ {
372
+ ref: '[card://video:mux-9b6586b494]',
373
+ title: 'Community demo',
374
+ url: 'https://stream.mux.com/playback-id.m3u8',
375
+ metadata: { videoUrl: 'javascript:alert(1)' },
376
+ },
377
+ {
378
+ ref: '[card://video:mp4-1b2047dc1b]',
379
+ title: 'Product overview',
380
+ url: 'https://cdn.example.test/product-overview.mp4',
381
+ metadata: { videoUrl: 'https://cdn.example.test/product-overview.mp4' },
382
+ },
383
+ {
384
+ ref: '[card://video:MdFJNoJeqZQ]',
385
+ title: 'Duplicate video',
386
+ url: 'https://cdn.example.test/duplicate.mp4',
387
+ },
388
+ ],
389
+ cards: [
390
+ {
391
+ ref: '[card://markdown:install-agent]',
392
+ entityType: 'markdown',
393
+ entityId: 'install-agent',
394
+ },
395
+ ],
396
+ },
397
+ }),
398
+ ).toEqual({
399
+ type: 'sources',
400
+ seq: 81,
401
+ sources: [
402
+ {
403
+ index: 1,
404
+ name: 'Install agent',
405
+ path: 'docs/install.md',
406
+ documentType: 'markdown',
407
+ externalUrl: 'https://docs.example.test/install',
408
+ },
409
+ {
410
+ index: 2,
411
+ name: 'Unsafe source',
412
+ path: 'docs/unsafe.md',
413
+ documentType: 'markdown',
414
+ },
415
+ ],
416
+ refs: [
417
+ {
418
+ type: 'video',
419
+ id: 'MdFJNoJeqZQ',
420
+ title: 'Install the agent',
421
+ url: 'https://www.youtube.com/watch?v=MdFJNoJeqZQ',
422
+ sourceRepo: 'embedded-videos',
423
+ metadata: { youtubeUrl: 'MdFJNoJeqZQ' },
424
+ },
425
+ {
426
+ type: 'video',
427
+ id: 'mux-9b6586b494',
428
+ title: 'Community demo',
429
+ url: 'https://stream.mux.com/playback-id.m3u8',
430
+ metadata: { videoUrl: 'https://stream.mux.com/playback-id.m3u8' },
431
+ },
432
+ {
433
+ type: 'video',
434
+ id: 'mp4-1b2047dc1b',
435
+ title: 'Product overview',
436
+ url: 'https://cdn.example.test/product-overview.mp4',
437
+ metadata: { videoUrl: 'https://cdn.example.test/product-overview.mp4' },
438
+ },
439
+ {
440
+ type: 'markdown',
441
+ id: 'install-agent',
442
+ title: 'install-agent',
443
+ url: null,
444
+ },
445
+ ],
446
+ });
447
+ });
448
+
449
+ it('drops a metadata payload with no valid sources or refs', () => {
450
+ expect(
451
+ decodeNatsChunk({
452
+ type: 'SOURCES',
453
+ payload: {
454
+ sources: [{ index: 0, name: 'Invalid', path: '', documentType: '' }],
455
+ videos: [{ ref: 'https://example.test/video' }],
456
+ cards: [{ ref: '[CARD://blog:wrong-case]' }],
457
+ },
458
+ }),
459
+ ).toBeNull();
460
+ });
461
+ });
@@ -12,7 +12,8 @@
12
12
  // rows straight to the accumulator, so restating them here would be two
13
13
  // declarations of one wire contract. Type-only import from a React-free
14
14
  // module; `nats-decoder.ts` already depends on the same file for MESSAGE_TYPE.
15
- import type { AskOptionData } from '../components/chat/types/message.types';
15
+ import type { ChatRef } from '../components/chat/chat-ref.types';
16
+ import type { AskOptionData, ChatSource } from '../components/chat/types/message.types';
16
17
  // The wire-frame shapes these events carry through are defined ONCE in
17
18
  // `./frames.ts` — reuse them here rather than restating their fields.
18
19
  import type { ApprovalRequestField, DecisionResolvedFrame, UsageTelemetry } from './frames';
@@ -240,6 +241,12 @@ export interface ChatMetadataEvent extends ChatStreamEventBase {
240
241
  };
241
242
  }
242
243
 
244
+ export interface SourcesEvent extends ChatStreamEventBase {
245
+ type: 'sources';
246
+ sources: ChatSource[];
247
+ refs?: ChatRef[];
248
+ }
249
+
243
250
  /** SSE usage frames — raw wire keys (snake_case) preserved. */
244
251
  export interface UsageEvent extends ChatStreamEventBase {
245
252
  type: 'usage';
@@ -362,6 +369,7 @@ export type ChatStreamEvent =
362
369
  | TicketEscalatedEvent
363
370
  | TicketEventEvent
364
371
  | ChatMetadataEvent
372
+ | SourcesEvent
365
373
  | UsageEvent
366
374
  | TokenUsageEvent
367
375
  | CompactionEvent
@@ -20,6 +20,7 @@ import { MESSAGE_TYPE } from '../components/chat/types/message.types';
20
20
  import type { AskOptionData } from '../components/chat/types/message.types';
21
21
  import { ESCALATION_STATE, escalationResolvedStatus } from './events';
22
22
  import type { ApprovalToolCall, ChatStreamEvent } from './events';
23
+ import { sourceMetadataEvent } from './source-metadata';
23
24
 
24
25
  /** Minimal structural view of a NATS chunk (see `ChunkData` in
25
26
  * `src/components/chat/types/network.types.ts`). */
@@ -125,6 +126,12 @@ export function decodeNatsChunk(chunk: unknown): ChatStreamEvent | null {
125
126
  }
126
127
  return null;
127
128
 
129
+ case MESSAGE_TYPE.GUIDE:
130
+ case MESSAGE_TYPE.SOURCES: {
131
+ const event = sourceMetadataEvent(data.payload);
132
+ return event ? { ...event, ...seq } : null;
133
+ }
134
+
128
135
  // An ask card is only an ask card with something to pick: a question and at
129
136
  // least one option. Anything less is dropped rather than rendered as an
130
137
  // empty card the user can't answer — the backend already falls back to a
@@ -0,0 +1,187 @@
1
+ import type { ChatRef } from '../components/chat/chat-ref.types';
2
+ import type { ChatSource } from '../components/chat/types/message.types';
3
+ import type { SourcesEvent } from './events';
4
+
5
+ const CARD_REFERENCE = /^\[card:\/\/([a-zA-Z0-9_-]+):([a-zA-Z0-9_-]+)\]$/;
6
+
7
+ function requiredString(value: unknown): string | null {
8
+ if (typeof value !== 'string') return null;
9
+ const normalized = value.trim();
10
+ return normalized ? normalized : null;
11
+ }
12
+
13
+ function optionalString(value: unknown): string | undefined {
14
+ return requiredString(value) ?? undefined;
15
+ }
16
+
17
+ function httpsUrl(value: unknown): string | undefined {
18
+ const candidate = optionalString(value);
19
+ if (!candidate) return undefined;
20
+ try {
21
+ return new URL(candidate).protocol === 'https:' ? candidate : undefined;
22
+ } catch {
23
+ return undefined;
24
+ }
25
+ }
26
+
27
+ function youtubeId(value: unknown): string | undefined {
28
+ const candidate = optionalString(value);
29
+ if (!candidate) return undefined;
30
+ if (/^[A-Za-z0-9_-]{11}$/.test(candidate)) return candidate;
31
+ const url = httpsUrl(candidate);
32
+ if (!url) return undefined;
33
+ const parsed = new URL(url);
34
+ const hostname = parsed.hostname.toLowerCase();
35
+ const youtubeHost = hostname === 'youtu.be' || hostname === 'youtube.com' || hostname.endsWith('.youtube.com');
36
+ if (!youtubeHost) return undefined;
37
+ const pathId =
38
+ hostname === 'youtu.be'
39
+ ? parsed.pathname.split('/').filter(Boolean)[0]
40
+ : parsed.pathname.match(/^\/(?:embed|v|shorts)\/([A-Za-z0-9_-]{11})(?:\/|$)/)?.[1];
41
+ const id = pathId ?? parsed.searchParams.get('v') ?? undefined;
42
+ return id && /^[A-Za-z0-9_-]{11}$/.test(id) ? id : undefined;
43
+ }
44
+
45
+ function nullableString(value: unknown): string | null | undefined {
46
+ return value === null ? null : optionalString(value);
47
+ }
48
+
49
+ function normalizeSourceItems(value: unknown): NonNullable<ChatSource['items']> | undefined {
50
+ if (!Array.isArray(value)) return undefined;
51
+ const items = value.flatMap(candidate => {
52
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return [];
53
+ const item = candidate as Record<string, unknown>;
54
+ const id = requiredString(item.id);
55
+ const documentType = requiredString(item.documentType);
56
+ const name = requiredString(item.name);
57
+ if (!id || !documentType || !name) return [];
58
+ const externalUrl = httpsUrl(item.externalUrl);
59
+ const targetPlatform = nullableString(item.targetPlatform);
60
+ const path = nullableString(item.path);
61
+ return [
62
+ {
63
+ id,
64
+ documentType,
65
+ name,
66
+ ...(externalUrl ? { externalUrl } : {}),
67
+ ...(targetPlatform !== undefined ? { targetPlatform } : {}),
68
+ ...(path !== undefined ? { path } : {}),
69
+ },
70
+ ];
71
+ });
72
+ return items.length > 0 ? items : undefined;
73
+ }
74
+
75
+ function normalizeSources(value: unknown): ChatSource[] {
76
+ if (!Array.isArray(value)) return [];
77
+ const seen = new Set<number>();
78
+ const sources: ChatSource[] = [];
79
+ for (const candidate of value) {
80
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) continue;
81
+ const source = candidate as Record<string, unknown>;
82
+ const index = source.index;
83
+ const name = requiredString(source.name);
84
+ const path = requiredString(source.path);
85
+ const documentType = requiredString(source.documentType);
86
+ const validIndex =
87
+ typeof index === 'number' && Number.isFinite(index) && Number.isInteger(index) && index > 0 && !seen.has(index);
88
+ if (!validIndex || !name || !path || !documentType) continue;
89
+ seen.add(index);
90
+ const externalUrl = httpsUrl(source.externalUrl);
91
+ const targetPlatform = nullableString(source.targetPlatform);
92
+ const id = optionalString(source.id);
93
+ const sourceRepo = optionalString(source.sourceRepo);
94
+ const label = optionalString(source.label);
95
+ const items = normalizeSourceItems(source.items);
96
+ sources.push({
97
+ index,
98
+ name,
99
+ path,
100
+ documentType,
101
+ ...(externalUrl ? { externalUrl } : {}),
102
+ ...(targetPlatform !== undefined ? { targetPlatform } : {}),
103
+ ...(id ? { id } : {}),
104
+ ...(sourceRepo ? { sourceRepo } : {}),
105
+ ...(label ? { label } : {}),
106
+ ...(items ? { items } : {}),
107
+ });
108
+ }
109
+ return sources;
110
+ }
111
+
112
+ function normalizeVideoMetadata(value: unknown): Record<string, string> {
113
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
114
+ const input = value as Record<string, unknown>;
115
+ const metadata: Record<string, string> = {};
116
+ for (const key of ['videoUrl', 'highlightVideoUrl', 'videoPoster', 'highlightVideoPoster'] as const) {
117
+ const url = httpsUrl(input[key]);
118
+ if (url) metadata[key] = url;
119
+ }
120
+ const id = youtubeId(input.youtubeUrl);
121
+ if (id) metadata.youtubeUrl = id;
122
+ return metadata;
123
+ }
124
+
125
+ function normalizeVideos(value: unknown): ChatRef[] {
126
+ if (!Array.isArray(value)) return [];
127
+ return value.flatMap(candidate => {
128
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return [];
129
+ const video = candidate as Record<string, unknown>;
130
+ const match = typeof video.ref === 'string' ? CARD_REFERENCE.exec(video.ref) : null;
131
+ if (!match || match[1] !== 'video') return [];
132
+ const id = match[2];
133
+ const title = optionalString(video.title) ?? id;
134
+ const url = httpsUrl(video.url) ?? null;
135
+ const sourceRepo = optionalString(video.sourceRepo);
136
+ const metadata = normalizeVideoMetadata(video.metadata);
137
+ if (url) {
138
+ const idFromUrl = youtubeId(url);
139
+ if (idFromUrl) {
140
+ metadata.youtubeUrl = idFromUrl;
141
+ delete metadata.videoUrl;
142
+ } else {
143
+ metadata.videoUrl = url;
144
+ delete metadata.youtubeUrl;
145
+ }
146
+ }
147
+ return [
148
+ {
149
+ type: 'video',
150
+ id,
151
+ title,
152
+ url,
153
+ ...(sourceRepo ? { sourceRepo } : {}),
154
+ ...(Object.keys(metadata).length > 0 ? { metadata } : {}),
155
+ },
156
+ ];
157
+ });
158
+ }
159
+
160
+ function normalizeCards(value: unknown): ChatRef[] {
161
+ if (!Array.isArray(value)) return [];
162
+ return value.flatMap(candidate => {
163
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return [];
164
+ const card = candidate as Record<string, unknown>;
165
+ const match = typeof card.ref === 'string' ? CARD_REFERENCE.exec(card.ref) : null;
166
+ if (!match || match[1] === 'video') return [];
167
+ const type = requiredString(card.entityType);
168
+ const id = requiredString(card.entityId);
169
+ if (!type || !id || type !== match[1] || id !== match[2]) return [];
170
+ return [{ type, id, title: id, url: null }];
171
+ });
172
+ }
173
+
174
+ export function sourceMetadataEvent(payload: unknown): SourcesEvent | null {
175
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
176
+ const metadata = payload as Record<string, unknown>;
177
+ const sources = normalizeSources(metadata.sources);
178
+ const refs = [...normalizeVideos(metadata.videos), ...normalizeCards(metadata.cards)];
179
+ const refsByIdentity = new Map<string, ChatRef>();
180
+ refs.forEach(ref => {
181
+ const identity = `${ref.type}:${ref.id}`;
182
+ if (!refsByIdentity.has(identity)) refsByIdentity.set(identity, ref);
183
+ });
184
+ const uniqueRefs = Array.from(refsByIdentity.values());
185
+ if (sources.length === 0 && uniqueRefs.length === 0) return null;
186
+ return { type: 'sources', sources, ...(uniqueRefs.length > 0 ? { refs: uniqueRefs } : {}) };
187
+ }
@@ -20,6 +20,7 @@
20
20
  import { render } from '@testing-library/react';
21
21
  import type React from 'react';
22
22
  import { describe, it, expect, vi } from 'vitest';
23
+ import type { ChatRef } from '../chat-ref.types';
23
24
  import type { MessageSegment } from '../types';
24
25
 
25
26
  /** Every `componentOverrides` / `additionalRemarkPlugins` identity the
@@ -129,4 +130,89 @@ describe('ChatMessageEnhanced — override identity across a streamed turn', ()
129
130
  expect(seenRefs).toEqual([{ type: 'blog', id: 'abc' }]);
130
131
  expect(view.container.textContent).toContain('card:abc');
131
132
  });
133
+
134
+ it('enriches exact video markers with YouTube, hashed Mux, and hashed MP4 metadata', () => {
135
+ const seenRefs: Array<{ id: string; metadata?: Record<string, unknown> }> = [];
136
+ const refs: ChatRef[] = [
137
+ {
138
+ type: 'video',
139
+ id: 'MdFJNoJeqZQ',
140
+ title: 'Install the agent',
141
+ url: 'https://www.youtube.com/watch?v=MdFJNoJeqZQ',
142
+ metadata: { youtubeUrl: 'MdFJNoJeqZQ' },
143
+ },
144
+ {
145
+ type: 'video',
146
+ id: 'mux-9b6586b494',
147
+ title: 'Community demo',
148
+ url: 'https://stream.mux.com/playback-id.m3u8',
149
+ metadata: { videoUrl: 'https://stream.mux.com/playback-id.m3u8' },
150
+ },
151
+ {
152
+ type: 'video',
153
+ id: 'mp4-1b2047dc1b',
154
+ title: 'Product overview',
155
+ url: 'https://cdn.example.test/product-overview.mp4',
156
+ metadata: { videoUrl: 'https://cdn.example.test/product-overview.mp4' },
157
+ },
158
+ ];
159
+
160
+ render(
161
+ <ChatMessageEnhanced
162
+ role="assistant"
163
+ content={[
164
+ {
165
+ type: 'text',
166
+ text: 'Watch [card://video:MdFJNoJeqZQ] [card://video:mux-9b6586b494] [card://video:mp4-1b2047dc1b].',
167
+ },
168
+ ]}
169
+ refs={refs}
170
+ renderEntityCard={ref => {
171
+ seenRefs.push({ id: ref.id, metadata: ref.metadata });
172
+ return <div data-testid={`card-${ref.id}`}>{ref.title}</div>;
173
+ }}
174
+ NavLinkAnchor={NavLinkAnchor}
175
+ />,
176
+ );
177
+
178
+ expect(seenRefs).toEqual([
179
+ { id: 'MdFJNoJeqZQ', metadata: { youtubeUrl: 'MdFJNoJeqZQ' } },
180
+ { id: 'mux-9b6586b494', metadata: { videoUrl: 'https://stream.mux.com/playback-id.m3u8' } },
181
+ { id: 'mp4-1b2047dc1b', metadata: { videoUrl: 'https://cdn.example.test/product-overview.mp4' } },
182
+ ]);
183
+ });
184
+
185
+ it('rerenders a completed marker when its enriched metadata arrives later', () => {
186
+ const seenMetadata: Array<Record<string, unknown> | undefined> = [];
187
+ const content: MessageSegment[] = [{ type: 'text', text: 'Watch [card://video:mux-9b6586b494].' }];
188
+ const props = {
189
+ role: 'assistant' as const,
190
+ content,
191
+ renderEntityCard: (ref: ChatRef) => {
192
+ seenMetadata.push(ref.metadata);
193
+ return <div>video</div>;
194
+ },
195
+ NavLinkAnchor,
196
+ };
197
+ const view = render(<ChatMessageEnhanced {...props} />);
198
+
199
+ view.rerender(
200
+ <ChatMessageEnhanced
201
+ {...props}
202
+ refs={[
203
+ {
204
+ type: 'video',
205
+ id: 'mux-9b6586b494',
206
+ title: 'Community demo',
207
+ url: 'https://stream.mux.com/playback-id.m3u8',
208
+ metadata: { videoUrl: 'https://stream.mux.com/playback-id.m3u8' },
209
+ },
210
+ ]}
211
+ />,
212
+ );
213
+
214
+ expect(seenMetadata[seenMetadata.length - 1]).toEqual({
215
+ videoUrl: 'https://stream.mux.com/playback-id.m3u8',
216
+ });
217
+ });
132
218
  });
@@ -0,0 +1,171 @@
1
+ import * as DialogPrimitive from '@radix-ui/react-dialog';
2
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3
+ import { fireEvent, render, screen } from '@testing-library/react';
4
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
5
+ import { decodeNatsChunk } from '../../../chat-protocol/nats-decoder';
6
+ import { type ChatRuntime, ChatRuntimeContext } from '../../../contexts/chat-runtime-context';
7
+ import { EmbeddableChat } from '../embeddable-chat';
8
+ import { createChatStreamReducer } from '../stream/chat-stream-reducer';
9
+ import type { UnifiedChatMessage, UnifiedChatState } from '../types/unified-chat-state.types';
10
+
11
+ vi.mock('@mux/mux-player-react', () => ({
12
+ default: ({ src }: { src?: string }) => <div data-testid="mux-player" data-src={src} />,
13
+ }));
14
+
15
+ const runtime: ChatRuntime = {
16
+ endpoints: {
17
+ chatStreamUrl: '/test/chat',
18
+ approvalToolUrl: '/test/approve',
19
+ commandsUrl: '/test/commands',
20
+ buildListUrl: () => null,
21
+ attachmentUploadUrl: '/test/upload',
22
+ attachmentViewUrlPrefix: '/test/view/',
23
+ identityUrl: '/test/identity',
24
+ },
25
+ navigation: { mode: 'host' },
26
+ source: 'openframe',
27
+ };
28
+
29
+ function stateFor(messages: UnifiedChatMessage[]): UnifiedChatState {
30
+ const noop = () => {};
31
+ const asyncNoop = async () => {};
32
+ return {
33
+ messages,
34
+ isLoading: false,
35
+ streamingPhase: 'idle',
36
+ sendMessage: asyncNoop,
37
+ stopMessage: noop,
38
+ clearMessages: noop,
39
+ discussRef: noop,
40
+ displayRef: noop,
41
+ currentProvider: null,
42
+ currentModelLabel: null,
43
+ currentContextWindowMaxTokens: null,
44
+ currentInputTokens: null,
45
+ currentOutputTokens: null,
46
+ currentCacheHitRatePct: null,
47
+ currentUsageBreakdown: null,
48
+ dialogs: [{ id: 'dialog-1', title: 'Sources' }],
49
+ activeDialogId: 'dialog-1',
50
+ selectDialog: noop,
51
+ startNewDialog: () => Promise.resolve(null),
52
+ deleteDialog: asyncNoop,
53
+ renameDialog: asyncNoop,
54
+ archiveDialog: asyncNoop,
55
+ isDialogsLoading: false,
56
+ dialogsError: false,
57
+ reloadDialogs: noop,
58
+ isMessagesLoading: false,
59
+ hasMoreDialogs: false,
60
+ loadMoreDialogs: asyncNoop,
61
+ hasMoreMessages: false,
62
+ loadMoreMessages: asyncNoop,
63
+ approveRequest: asyncNoop,
64
+ rejectRequest: asyncNoop,
65
+ dialogTokenUsage: null,
66
+ connectionState: 'connected',
67
+ };
68
+ }
69
+
70
+ function renderChat(messages: UnifiedChatMessage[]) {
71
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
72
+ return render(
73
+ <QueryClientProvider client={queryClient}>
74
+ <ChatRuntimeContext.Provider value={runtime}>
75
+ <DialogPrimitive.Root open>
76
+ <EmbeddableChat
77
+ shell="none"
78
+ defaultOpen
79
+ defaultActiveMode="mingo"
80
+ showInternalTrigger={false}
81
+ previewMode
82
+ mingoState={stateFor(messages)}
83
+ />
84
+ </DialogPrimitive.Root>
85
+ </ChatRuntimeContext.Provider>
86
+ </QueryClientProvider>,
87
+ );
88
+ }
89
+
90
+ beforeAll(() => {
91
+ vi.stubGlobal('matchMedia', () => ({
92
+ matches: false,
93
+ addEventListener: vi.fn(),
94
+ removeEventListener: vi.fn(),
95
+ }));
96
+ vi.stubGlobal(
97
+ 'ResizeObserver',
98
+ class {
99
+ observe(): void {}
100
+ disconnect(): void {}
101
+ unobserve(): void {}
102
+ },
103
+ );
104
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
105
+ new Response(JSON.stringify({ commands: [] }), {
106
+ status: 200,
107
+ headers: { 'Content-Type': 'application/json' },
108
+ }),
109
+ );
110
+ });
111
+
112
+ afterAll(() => {
113
+ vi.restoreAllMocks();
114
+ vi.unstubAllGlobals();
115
+ });
116
+
117
+ describe('EmbeddableChat source strip', () => {
118
+ it('renders live SOURCES metadata in citation order with uncited overflow', () => {
119
+ const reducer = createChatStreamReducer({ transport: 'nats' });
120
+ for (const chunk of [
121
+ { type: 'MESSAGE_START', streamSeq: 1 },
122
+ {
123
+ type: 'SOURCES',
124
+ payload: {
125
+ sources: [
126
+ { index: 1, name: 'First source', path: 'first', documentType: 'docs' },
127
+ { index: 2, name: 'Second source', path: 'second', documentType: 'docs' },
128
+ { index: 3, name: 'Third source', path: 'third', documentType: 'docs' },
129
+ { index: 4, name: 'Fourth source', path: 'fourth', documentType: 'docs' },
130
+ ],
131
+ videos: [
132
+ {
133
+ ref: '[card://video:mux-9b6586b494]',
134
+ title: 'Product overview',
135
+ url: 'https://stream.mux.com/product-overview.m3u8',
136
+ },
137
+ ],
138
+ },
139
+ streamSeq: 2,
140
+ },
141
+ {
142
+ type: 'TEXT',
143
+ text:
144
+ 'Compare the third source [3] with the first [1] and cite [3] once. ' +
145
+ 'Watch [card://video:mux-9b6586b494].',
146
+ streamSeq: 3,
147
+ },
148
+ { type: 'MESSAGE_END', streamSeq: 4 },
149
+ ]) {
150
+ const event = decodeNatsChunk(chunk);
151
+ if (event) reducer.apply(event);
152
+ }
153
+
154
+ renderChat(reducer.state.messages);
155
+
156
+ const chips = screen.getAllByTitle(/source$/i);
157
+ expect(chips.map(chip => chip.textContent)).toEqual(['[3] Third source', '[1] First source']);
158
+ expect(screen.queryByText('[2] Second source')).not.toBeInTheDocument();
159
+ const expander = screen.getByRole('button', { name: 'Show 2 additional retrieved sources' });
160
+ expect(expander).toHaveTextContent('+2 more retrieved sources');
161
+
162
+ fireEvent.click(expander);
163
+
164
+ expect(screen.getByText('[2] Second source')).toBeInTheDocument();
165
+ expect(screen.getByText('[4] Fourth source')).toBeInTheDocument();
166
+ expect(screen.getByTestId('mux-player')).toHaveAttribute(
167
+ 'data-src',
168
+ 'https://stream.mux.com/product-overview.m3u8',
169
+ );
170
+ });
171
+ });