@flamingo-stack/openframe-frontend-core 0.0.586 → 0.0.587-1973.5528.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 (161) 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 +188 -0
  4. package/dist/chat-protocol/index.cjs.map +1 -1
  5. package/dist/chat-protocol/index.js +188 -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 +5 -0
  9. package/dist/chat-protocol/source-metadata.d.ts.map +1 -0
  10. package/dist/{chunk-BMORFCT2.cjs → chunk-3UJMZADA.cjs} +43 -43
  11. package/dist/{chunk-BMORFCT2.cjs.map → chunk-3UJMZADA.cjs.map} +1 -1
  12. package/dist/{chunk-D7YISHXC.js → chunk-4MVGFYRE.js} +534 -190
  13. package/dist/chunk-4MVGFYRE.js.map +1 -0
  14. package/dist/{chunk-3WR5R6H6.cjs → chunk-4S7GMJYA.cjs} +21 -21
  15. package/dist/{chunk-3WR5R6H6.cjs.map → chunk-4S7GMJYA.cjs.map} +1 -1
  16. package/dist/{chunk-EAYMX22G.cjs → chunk-5VCDP6UA.cjs} +10 -10
  17. package/dist/{chunk-EAYMX22G.cjs.map → chunk-5VCDP6UA.cjs.map} +1 -1
  18. package/dist/{chunk-XMTGLQSD.js → chunk-5X7AJ67N.js} +3 -3
  19. package/dist/{chunk-OZNFPQT5.js → chunk-7A6J4JTW.js} +3 -3
  20. package/dist/{chunk-QZYYNBMI.cjs → chunk-7COFWOCJ.cjs} +22 -22
  21. package/dist/{chunk-QZYYNBMI.cjs.map → chunk-7COFWOCJ.cjs.map} +1 -1
  22. package/dist/{chunk-6ZNWY6WW.cjs → chunk-AIUIIGO3.cjs} +24 -24
  23. package/dist/{chunk-6ZNWY6WW.cjs.map → chunk-AIUIIGO3.cjs.map} +1 -1
  24. package/dist/{chunk-M36XSGZV.cjs → chunk-BLC4JOFC.cjs} +15 -15
  25. package/dist/{chunk-M36XSGZV.cjs.map → chunk-BLC4JOFC.cjs.map} +1 -1
  26. package/dist/{chunk-NISAGFEM.cjs → chunk-DSXD77KX.cjs} +3 -3
  27. package/dist/{chunk-NISAGFEM.cjs.map → chunk-DSXD77KX.cjs.map} +1 -1
  28. package/dist/{chunk-P2CKLIOM.cjs → chunk-DVMQV3M4.cjs} +69 -69
  29. package/dist/{chunk-P2CKLIOM.cjs.map → chunk-DVMQV3M4.cjs.map} +1 -1
  30. package/dist/{chunk-S7YXLZNG.js → chunk-E2QFZDCP.js} +5 -5
  31. package/dist/{chunk-G45RGWHF.cjs → chunk-EKKR5ZFB.cjs} +887 -543
  32. package/dist/chunk-EKKR5ZFB.cjs.map +1 -0
  33. package/dist/{chunk-L4WLYGB4.js → chunk-FDWGO244.js} +2 -2
  34. package/dist/{chunk-3JVYM4ZN.js → chunk-JQO47UHC.js} +5 -5
  35. package/dist/{chunk-C3MRLZUI.cjs → chunk-JR37UTA2.cjs} +33 -33
  36. package/dist/{chunk-C3MRLZUI.cjs.map → chunk-JR37UTA2.cjs.map} +1 -1
  37. package/dist/{chunk-LV6CORVJ.js → chunk-KQWI25NP.js} +2 -2
  38. package/dist/{chunk-V7ZJTUV2.cjs → chunk-NEI7XMCM.cjs} +9 -9
  39. package/dist/{chunk-V7ZJTUV2.cjs.map → chunk-NEI7XMCM.cjs.map} +1 -1
  40. package/dist/{chunk-YEQAA6I4.js → chunk-OW3FUJ2I.js} +3 -3
  41. package/dist/{chunk-PLMJVA2H.js → chunk-OZRT3ZI3.js} +3 -3
  42. package/dist/{chunk-XMK24INA.cjs → chunk-QKAUBYU5.cjs} +3 -3
  43. package/dist/{chunk-XMK24INA.cjs.map → chunk-QKAUBYU5.cjs.map} +1 -1
  44. package/dist/{chunk-6EZGJZVH.js → chunk-QSO24MEK.js} +2 -2
  45. package/dist/{chunk-DQUEVRDU.js → chunk-QYDIVL4X.js} +6 -6
  46. package/dist/{chunk-3HYHYKNL.js → chunk-RE3KVI2Q.js} +5 -5
  47. package/dist/{chunk-KLWJRGNN.js → chunk-RQ5PYSRJ.js} +4 -4
  48. package/dist/{chunk-NYLWMAHP.cjs → chunk-S2UY2C4A.cjs} +10 -10
  49. package/dist/{chunk-NYLWMAHP.cjs.map → chunk-S2UY2C4A.cjs.map} +1 -1
  50. package/dist/{chunk-SSGM2NKA.cjs → chunk-SCVIBVFS.cjs} +4 -4
  51. package/dist/{chunk-SSGM2NKA.cjs.map → chunk-SCVIBVFS.cjs.map} +1 -1
  52. package/dist/{chunk-4VZFJKPT.cjs → chunk-ULOMJH7C.cjs} +5 -5
  53. package/dist/{chunk-4VZFJKPT.cjs.map → chunk-ULOMJH7C.cjs.map} +1 -1
  54. package/dist/{chunk-5OIXAV4P.js → chunk-UODM3HJC.js} +3 -3
  55. package/dist/{chunk-A6ETQEWK.cjs → chunk-UYHETUX3.cjs} +159 -159
  56. package/dist/{chunk-A6ETQEWK.cjs.map → chunk-UYHETUX3.cjs.map} +1 -1
  57. package/dist/{chunk-2Y5UT5VF.js → chunk-WFWWQO2I.js} +2 -2
  58. package/dist/{chunk-YYC7V7YC.js → chunk-ZRFSUCAH.js} +2 -2
  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/embeddable-chat.d.ts.map +1 -1
  64. package/dist/components/chat/entity-cards/dispatch.d.ts.map +1 -1
  65. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +4 -1
  66. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  67. package/dist/components/chat/hooks/use-slash-commands.d.ts +3 -3
  68. package/dist/components/chat/hooks/use-slash-commands.d.ts.map +1 -1
  69. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +2 -28
  70. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  71. package/dist/components/chat/index.cjs +2 -2
  72. package/dist/components/chat/index.js +1 -1
  73. package/dist/components/chat/remark-strip-citations.d.ts.map +1 -1
  74. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -1
  75. package/dist/components/chat/tool-execution-display.d.ts.map +1 -1
  76. package/dist/components/chat/types/component.types.d.ts +4 -0
  77. package/dist/components/chat/types/component.types.d.ts.map +1 -1
  78. package/dist/components/chat/types/message.types.d.ts +35 -1
  79. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  80. package/dist/components/chat/types/unified-chat-state.types.d.ts +3 -3
  81. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
  82. package/dist/components/chat/utils/chat-source-metadata.d.ts +5 -0
  83. package/dist/components/chat/utils/chat-source-metadata.d.ts.map +1 -0
  84. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  85. package/dist/components/contact/index.cjs +4 -4
  86. package/dist/components/contact/index.js +3 -3
  87. package/dist/components/docs/index.cjs +7 -7
  88. package/dist/components/docs/index.js +6 -6
  89. package/dist/components/embeds/index.cjs +4 -4
  90. package/dist/components/embeds/index.js +3 -3
  91. package/dist/components/faq/index.cjs +4 -4
  92. package/dist/components/faq/index.js +3 -3
  93. package/dist/components/features/index.cjs +3 -3
  94. package/dist/components/features/index.js +2 -2
  95. package/dist/components/help-center-pages/index.cjs +23 -23
  96. package/dist/components/help-center-pages/index.js +14 -14
  97. package/dist/components/index.cjs +36 -36
  98. package/dist/components/index.js +13 -13
  99. package/dist/components/meeting-scheduler/index.cjs +21 -21
  100. package/dist/components/meeting-scheduler/index.js +2 -2
  101. package/dist/components/navigation/index.cjs +6 -6
  102. package/dist/components/navigation/index.js +5 -5
  103. package/dist/components/onboarding-guides/index.cjs +6 -6
  104. package/dist/components/onboarding-guides/index.js +5 -5
  105. package/dist/components/tickets/index.cjs +7 -7
  106. package/dist/components/tickets/index.js +6 -6
  107. package/dist/components/ui/hover-dropdown.d.ts.map +1 -1
  108. package/dist/components/ui/index.cjs +3 -3
  109. package/dist/components/ui/index.js +2 -2
  110. package/dist/index.cjs +7 -7
  111. package/dist/index.js +6 -6
  112. package/dist/utils/index.cjs.map +1 -1
  113. package/dist/utils/index.js.map +1 -1
  114. package/package.json +2 -1
  115. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +130 -0
  116. package/src/chat-protocol/__tests__/source-metadata.test.ts +74 -0
  117. package/src/chat-protocol/events.ts +9 -1
  118. package/src/chat-protocol/nats-decoder.ts +7 -0
  119. package/src/chat-protocol/source-metadata.ts +202 -0
  120. package/src/components/chat/__tests__/chat-message-list.test.tsx +70 -0
  121. package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +86 -0
  122. package/src/components/chat/__tests__/embeddable-chat-sources.test.tsx +411 -0
  123. package/src/components/chat/__tests__/remark-strip-citations.test.ts +52 -0
  124. package/src/components/chat/__tests__/tool-execution-display.test.tsx +93 -0
  125. package/src/components/chat/chat-message-enhanced.tsx +28 -13
  126. package/src/components/chat/chat-message-list.tsx +27 -22
  127. package/src/components/chat/embeddable-chat.tsx +55 -36
  128. package/src/components/chat/entity-cards/__tests__/dispatch-video-refs.test.tsx +74 -0
  129. package/src/components/chat/entity-cards/dispatch.tsx +86 -17
  130. package/src/components/chat/hooks/__tests__/use-slash-commands.test.ts +64 -0
  131. package/src/components/chat/hooks/use-nats-chat-adapter.ts +7 -0
  132. package/src/components/chat/hooks/use-slash-commands.ts +9 -5
  133. package/src/components/chat/hooks/use-sse-chat-adapter.ts +2 -29
  134. package/src/components/chat/remark-strip-citations.ts +5 -3
  135. package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +56 -0
  136. package/src/components/chat/stream/chat-stream-reducer.ts +31 -0
  137. package/src/components/chat/tool-execution-display.tsx +5 -2
  138. package/src/components/chat/types/component.types.ts +4 -0
  139. package/src/components/chat/types/message.types.ts +39 -0
  140. package/src/components/chat/types/unified-chat-state.types.ts +4 -3
  141. package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +57 -0
  142. package/src/components/chat/utils/chat-source-metadata.ts +19 -0
  143. package/src/components/chat/utils/process-historical-messages.ts +19 -1
  144. package/src/components/ui/hover-dropdown.tsx +8 -3
  145. package/dist/chunk-D7YISHXC.js.map +0 -1
  146. package/dist/chunk-G45RGWHF.cjs.map +0 -1
  147. /package/dist/{chunk-XMTGLQSD.js.map → chunk-5X7AJ67N.js.map} +0 -0
  148. /package/dist/{chunk-OZNFPQT5.js.map → chunk-7A6J4JTW.js.map} +0 -0
  149. /package/dist/{chunk-S7YXLZNG.js.map → chunk-E2QFZDCP.js.map} +0 -0
  150. /package/dist/{chunk-L4WLYGB4.js.map → chunk-FDWGO244.js.map} +0 -0
  151. /package/dist/{chunk-3JVYM4ZN.js.map → chunk-JQO47UHC.js.map} +0 -0
  152. /package/dist/{chunk-LV6CORVJ.js.map → chunk-KQWI25NP.js.map} +0 -0
  153. /package/dist/{chunk-YEQAA6I4.js.map → chunk-OW3FUJ2I.js.map} +0 -0
  154. /package/dist/{chunk-PLMJVA2H.js.map → chunk-OZRT3ZI3.js.map} +0 -0
  155. /package/dist/{chunk-6EZGJZVH.js.map → chunk-QSO24MEK.js.map} +0 -0
  156. /package/dist/{chunk-DQUEVRDU.js.map → chunk-QYDIVL4X.js.map} +0 -0
  157. /package/dist/{chunk-3HYHYKNL.js.map → chunk-RE3KVI2Q.js.map} +0 -0
  158. /package/dist/{chunk-KLWJRGNN.js.map → chunk-RQ5PYSRJ.js.map} +0 -0
  159. /package/dist/{chunk-5OIXAV4P.js.map → chunk-UODM3HJC.js.map} +0 -0
  160. /package/dist/{chunk-2Y5UT5VF.js.map → chunk-WFWWQO2I.js.map} +0 -0
  161. /package/dist/{chunk-YYC7V7YC.js.map → chunk-ZRFSUCAH.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.586",
3
+ "version": "0.0.587-1973.5528.1",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -444,6 +444,7 @@
444
444
  "@hookform/resolvers": "~5.2.2",
445
445
  "@storybook/nextjs-vite": "^10.1.11",
446
446
  "@svgr/cli": "^8.1.0",
447
+ "@tanstack/react-query": "^5.101.4",
447
448
  "@testing-library/jest-dom": "^6.9.1",
448
449
  "@testing-library/react": "^16.3.1",
449
450
  "@types/node": "^24.10.4",
@@ -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
+ });
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { isSourceMetadataCardRef, sourceMetadataEvent } from '../source-metadata';
3
+
4
+ describe('source metadata card provenance', () => {
5
+ it('is recognized by a separate package entrypoint module instance', async () => {
6
+ const event = sourceMetadataEvent({
7
+ sources: [
8
+ {
9
+ index: 1,
10
+ id: 'install-agent',
11
+ name: 'Install the OpenFrame agent on Windows',
12
+ path: 'getting-started/install-agent.md',
13
+ documentType: 'markdown',
14
+ },
15
+ ],
16
+ cards: [
17
+ {
18
+ ref: '[card://markdown:install-agent]',
19
+ entityType: 'markdown',
20
+ entityId: 'install-agent',
21
+ },
22
+ ],
23
+ });
24
+ const ref = event?.refs?.[0];
25
+ if (!ref) throw new Error('Expected matching source metadata card ref');
26
+
27
+ vi.resetModules();
28
+ const { isSourceMetadataCardRef: isSourceMetadataCardRefFromSecondEntrypoint } = await import('../source-metadata');
29
+
30
+ expect(isSourceMetadataCardRefFromSecondEntrypoint(ref)).toBe(true);
31
+ });
32
+
33
+ it('keeps source provenance after a serializing host boundary', () => {
34
+ const event = sourceMetadataEvent({
35
+ sources: [
36
+ {
37
+ index: 1,
38
+ id: 'install-agent',
39
+ name: 'Install the OpenFrame agent on Windows',
40
+ path: 'getting-started/install-agent.md',
41
+ documentType: 'markdown',
42
+ },
43
+ ],
44
+ cards: [
45
+ {
46
+ ref: '[card://markdown:install-agent]',
47
+ entityType: 'markdown',
48
+ entityId: 'install-agent',
49
+ },
50
+ ],
51
+ });
52
+ const ref = event?.refs?.[0];
53
+ if (!ref) throw new Error('Expected matching source metadata card ref');
54
+
55
+ const restoredRef = structuredClone(ref);
56
+
57
+ expect(isSourceMetadataCardRef(restoredRef)).toBe(true);
58
+ });
59
+
60
+ it('does not recognize an arbitrary enriched card ref', async () => {
61
+ vi.resetModules();
62
+ const { isSourceMetadataCardRef: isSourceMetadataCardRefFromSecondEntrypoint } = await import('../source-metadata');
63
+
64
+ expect(
65
+ isSourceMetadataCardRefFromSecondEntrypoint({
66
+ type: 'markdown',
67
+ id: 'unmatched-doc',
68
+ title: 'Plausible but unmatched document',
69
+ url: null,
70
+ metadata: { path: 'docs/unmatched.md' },
71
+ }),
72
+ ).toBe(false);
73
+ });
74
+ });
@@ -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,202 @@
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
+ const SOURCE_METADATA_MATCHED = 'sourceMetadataMatched';
7
+
8
+ export function isSourceMetadataCardRef(ref: ChatRef): boolean {
9
+ return ref.metadata?.[SOURCE_METADATA_MATCHED] === true;
10
+ }
11
+
12
+ function requiredString(value: unknown): string | null {
13
+ if (typeof value !== 'string') return null;
14
+ const normalized = value.trim();
15
+ return normalized ? normalized : null;
16
+ }
17
+
18
+ function optionalString(value: unknown): string | undefined {
19
+ return requiredString(value) ?? undefined;
20
+ }
21
+
22
+ function httpsUrl(value: unknown): string | undefined {
23
+ const candidate = optionalString(value);
24
+ if (!candidate) return undefined;
25
+ try {
26
+ return new URL(candidate).protocol === 'https:' ? candidate : undefined;
27
+ } catch {
28
+ return undefined;
29
+ }
30
+ }
31
+
32
+ function youtubeId(value: unknown): string | undefined {
33
+ const candidate = optionalString(value);
34
+ if (!candidate) return undefined;
35
+ if (/^[A-Za-z0-9_-]{11}$/.test(candidate)) return candidate;
36
+ const url = httpsUrl(candidate);
37
+ if (!url) return undefined;
38
+ const parsed = new URL(url);
39
+ const hostname = parsed.hostname.toLowerCase();
40
+ const youtubeHost = hostname === 'youtu.be' || hostname === 'youtube.com' || hostname.endsWith('.youtube.com');
41
+ if (!youtubeHost) return undefined;
42
+ const pathId =
43
+ hostname === 'youtu.be'
44
+ ? parsed.pathname.split('/').filter(Boolean)[0]
45
+ : parsed.pathname.match(/^\/(?:embed|v|shorts)\/([A-Za-z0-9_-]{11})(?:\/|$)/)?.[1];
46
+ const id = pathId ?? parsed.searchParams.get('v') ?? undefined;
47
+ return id && /^[A-Za-z0-9_-]{11}$/.test(id) ? id : undefined;
48
+ }
49
+
50
+ function nullableString(value: unknown): string | null | undefined {
51
+ return value === null ? null : optionalString(value);
52
+ }
53
+
54
+ function normalizeSourceItems(value: unknown): NonNullable<ChatSource['items']> | undefined {
55
+ if (!Array.isArray(value)) return undefined;
56
+ const items = value.flatMap(candidate => {
57
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return [];
58
+ const item = candidate as Record<string, unknown>;
59
+ const id = requiredString(item.id);
60
+ const documentType = requiredString(item.documentType);
61
+ const name = requiredString(item.name);
62
+ if (!id || !documentType || !name) return [];
63
+ const externalUrl = httpsUrl(item.externalUrl);
64
+ const targetPlatform = nullableString(item.targetPlatform);
65
+ const path = nullableString(item.path);
66
+ return [
67
+ {
68
+ id,
69
+ documentType,
70
+ name,
71
+ ...(externalUrl ? { externalUrl } : {}),
72
+ ...(targetPlatform !== undefined ? { targetPlatform } : {}),
73
+ ...(path !== undefined ? { path } : {}),
74
+ },
75
+ ];
76
+ });
77
+ return items.length > 0 ? items : undefined;
78
+ }
79
+
80
+ function normalizeSources(value: unknown): ChatSource[] {
81
+ if (!Array.isArray(value)) return [];
82
+ const seen = new Set<number>();
83
+ const sources: ChatSource[] = [];
84
+ for (const candidate of value) {
85
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) continue;
86
+ const source = candidate as Record<string, unknown>;
87
+ const index = source.index;
88
+ const name = requiredString(source.name);
89
+ const path = requiredString(source.path);
90
+ const documentType = requiredString(source.documentType);
91
+ const validIndex =
92
+ typeof index === 'number' && Number.isFinite(index) && Number.isInteger(index) && index > 0 && !seen.has(index);
93
+ if (!validIndex || !name || !path || !documentType) continue;
94
+ seen.add(index);
95
+ const externalUrl = httpsUrl(source.externalUrl);
96
+ const targetPlatform = nullableString(source.targetPlatform);
97
+ const id = optionalString(source.id);
98
+ const sourceRepo = optionalString(source.sourceRepo);
99
+ const label = optionalString(source.label);
100
+ const items = normalizeSourceItems(source.items);
101
+ sources.push({
102
+ index,
103
+ name,
104
+ path,
105
+ documentType,
106
+ ...(externalUrl ? { externalUrl } : {}),
107
+ ...(targetPlatform !== undefined ? { targetPlatform } : {}),
108
+ ...(id ? { id } : {}),
109
+ ...(sourceRepo ? { sourceRepo } : {}),
110
+ ...(label ? { label } : {}),
111
+ ...(items ? { items } : {}),
112
+ });
113
+ }
114
+ return sources;
115
+ }
116
+
117
+ function normalizeVideoMetadata(value: unknown): Record<string, string> {
118
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
119
+ const input = value as Record<string, unknown>;
120
+ const metadata: Record<string, string> = {};
121
+ for (const key of ['videoUrl', 'highlightVideoUrl', 'videoPoster', 'highlightVideoPoster'] as const) {
122
+ const url = httpsUrl(input[key]);
123
+ if (url) metadata[key] = url;
124
+ }
125
+ const id = youtubeId(input.youtubeUrl);
126
+ if (id) metadata.youtubeUrl = id;
127
+ return metadata;
128
+ }
129
+
130
+ function normalizeVideos(value: unknown): ChatRef[] {
131
+ if (!Array.isArray(value)) return [];
132
+ return value.flatMap(candidate => {
133
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return [];
134
+ const video = candidate as Record<string, unknown>;
135
+ const match = typeof video.ref === 'string' ? CARD_REFERENCE.exec(video.ref) : null;
136
+ if (!match || match[1] !== 'video') return [];
137
+ const id = match[2];
138
+ const title = optionalString(video.title) ?? id;
139
+ const url = httpsUrl(video.url) ?? null;
140
+ const sourceRepo = optionalString(video.sourceRepo);
141
+ const metadata = normalizeVideoMetadata(video.metadata);
142
+ if (url) {
143
+ const idFromUrl = youtubeId(url);
144
+ if (idFromUrl) {
145
+ metadata.youtubeUrl = idFromUrl;
146
+ delete metadata.videoUrl;
147
+ } else {
148
+ metadata.videoUrl = url;
149
+ delete metadata.youtubeUrl;
150
+ }
151
+ }
152
+ return [
153
+ {
154
+ type: 'video',
155
+ id,
156
+ title,
157
+ url,
158
+ ...(sourceRepo ? { sourceRepo } : {}),
159
+ ...(Object.keys(metadata).length > 0 ? { metadata } : {}),
160
+ },
161
+ ];
162
+ });
163
+ }
164
+
165
+ function normalizeCards(value: unknown, sources: ChatSource[]): ChatRef[] {
166
+ if (!Array.isArray(value)) return [];
167
+ return value.flatMap(candidate => {
168
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return [];
169
+ const card = candidate as Record<string, unknown>;
170
+ const match = typeof card.ref === 'string' ? CARD_REFERENCE.exec(card.ref) : null;
171
+ if (!match || match[1] === 'video') return [];
172
+ const type = requiredString(card.entityType);
173
+ const id = requiredString(card.entityId);
174
+ if (!type || !id || type !== match[1] || id !== match[2]) return [];
175
+ const source = sources.find(item => item.id === id && item.documentType === type);
176
+ const ref: ChatRef = {
177
+ type,
178
+ id,
179
+ title: source?.name ?? id,
180
+ url: source?.externalUrl ?? null,
181
+ ...(source?.sourceRepo ? { sourceRepo: source.sourceRepo } : {}),
182
+ ...(source?.targetPlatform !== undefined ? { targetPlatform: source.targetPlatform } : {}),
183
+ ...(source?.path ? { metadata: { path: source.path, [SOURCE_METADATA_MATCHED]: true } } : {}),
184
+ };
185
+ return [ref];
186
+ });
187
+ }
188
+
189
+ export function sourceMetadataEvent(payload: unknown): SourcesEvent | null {
190
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
191
+ const metadata = payload as Record<string, unknown>;
192
+ const sources = normalizeSources(metadata.sources);
193
+ const refs = [...normalizeVideos(metadata.videos), ...normalizeCards(metadata.cards, sources)];
194
+ const refsByIdentity = new Map<string, ChatRef>();
195
+ refs.forEach(ref => {
196
+ const identity = `${ref.type}:${ref.id}`;
197
+ if (!refsByIdentity.has(identity)) refsByIdentity.set(identity, ref);
198
+ });
199
+ const uniqueRefs = Array.from(refsByIdentity.values());
200
+ if (sources.length === 0 && uniqueRefs.length === 0) return null;
201
+ return { type: 'sources', sources, ...(uniqueRefs.length > 0 ? { refs: uniqueRefs } : {}) };
202
+ }
@@ -146,6 +146,76 @@ describe('ChatMessageList force-scroll decisions', () => {
146
146
  });
147
147
  });
148
148
 
149
+ describe('ChatMessageList message footers', () => {
150
+ const renderSourceFooter = (message: Message) =>
151
+ message.sources?.map(source => <span key={`${message.id}-${source.index}`}>{source.name}</span>);
152
+
153
+ it('keeps a sourced assistant footer after a later plain assistant message', () => {
154
+ const sourcedAssistant: Message = {
155
+ ...msg('a1', 'assistant'),
156
+ sources: [
157
+ {
158
+ index: 1,
159
+ name: 'Windows installation guide',
160
+ path: '/docs/windows',
161
+ documentType: 'openframe-docs',
162
+ },
163
+ ],
164
+ };
165
+ const plainAssistant = msg('a2', 'assistant');
166
+
167
+ render(
168
+ <ChatMessageList
169
+ dialogId="d1"
170
+ messages={[sourcedAssistant, plainAssistant]}
171
+ renderAfterMessage={renderSourceFooter}
172
+ />,
173
+ );
174
+
175
+ const sourceFooter = screen.getByText('Windows installation guide');
176
+ expect(sourceFooter).toBeInTheDocument();
177
+ expect(screen.getByText('body of a1')).toAppearBefore(sourceFooter);
178
+ });
179
+
180
+ it('renders separate source names when citation indices restart on each turn', () => {
181
+ const firstAssistant: Message = {
182
+ ...msg('a1', 'assistant'),
183
+ sources: [
184
+ {
185
+ index: 1,
186
+ name: 'First turn source',
187
+ path: '/docs/first',
188
+ documentType: 'openframe-docs',
189
+ },
190
+ ],
191
+ };
192
+ const secondAssistant: Message = {
193
+ ...msg('a2', 'assistant'),
194
+ sources: [
195
+ {
196
+ index: 1,
197
+ name: 'Second turn source',
198
+ path: '/docs/second',
199
+ documentType: 'openframe-docs',
200
+ },
201
+ ],
202
+ };
203
+
204
+ render(
205
+ <ChatMessageList
206
+ dialogId="d1"
207
+ messages={[firstAssistant, secondAssistant]}
208
+ renderAfterMessage={renderSourceFooter}
209
+ />,
210
+ );
211
+
212
+ const firstSourceFooter = screen.getByText('First turn source');
213
+ const secondSourceFooter = screen.getByText('Second turn source');
214
+ expect(screen.getByText('body of a1')).toAppearBefore(firstSourceFooter);
215
+ expect(screen.getByText('body of a2')).toAppearBefore(secondSourceFooter);
216
+ });
217
+ });
218
+
149
219
  // The library's own `isAtBottom` lock is lost silently when the scroller's
150
220
  // box changes (source chips mounting below it) or when a card settling out
151
221
  // of its skeleton produces a resize-driven scroll the library reads as a
@@ -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
  });