@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
@@ -0,0 +1,93 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
3
+ import { decodeNatsChunk } from '../../../chat-protocol/nats-decoder';
4
+ import { createChatStreamReducer } from '../stream/chat-stream-reducer';
5
+ import { ToolExecutionDisplay } from '../tool-execution-display';
6
+ import type { ToolExecutionData } from '../types';
7
+
8
+ const execution = (overrides: Partial<ToolExecutionData>): ToolExecutionData => ({
9
+ type: 'EXECUTING_TOOL',
10
+ integratedToolType: 'OPENFRAME',
11
+ toolFunction: 'search_docs',
12
+ ...overrides,
13
+ });
14
+
15
+ beforeAll(() => {
16
+ vi.stubGlobal(
17
+ 'ResizeObserver',
18
+ class {
19
+ observe(): void {}
20
+ disconnect(): void {}
21
+ unobserve(): void {}
22
+ },
23
+ );
24
+ });
25
+
26
+ afterAll(() => {
27
+ vi.unstubAllGlobals();
28
+ });
29
+
30
+ describe('ToolExecutionDisplay remote read activity', () => {
31
+ it('keeps a completed remote read concise through the real wire pipeline', () => {
32
+ const reducer = createChatStreamReducer({ transport: 'nats' });
33
+ for (const chunk of [
34
+ {
35
+ type: 'EXECUTING_TOOL',
36
+ integratedToolType: null,
37
+ toolFunction: 'remote_read',
38
+ title: 'Searching product documentation',
39
+ parameters: null,
40
+ toolExecutionRequestId: 'remote-read-1',
41
+ },
42
+ {
43
+ type: 'EXECUTED_TOOL',
44
+ integratedToolType: null,
45
+ toolFunction: 'remote_read',
46
+ result: null,
47
+ success: true,
48
+ toolExecutionRequestId: 'remote-read-1',
49
+ },
50
+ ]) {
51
+ const event = decodeNatsChunk(chunk);
52
+ if (event) reducer.apply(event);
53
+ }
54
+ const toolSegment = reducer.state.messages
55
+ .flatMap(message => message.segments ?? [])
56
+ .find(segment => segment.type === 'tool_execution');
57
+
58
+ expect(toolSegment?.type).toBe('tool_execution');
59
+ if (toolSegment?.type !== 'tool_execution') return;
60
+ render(<ToolExecutionDisplay message={toolSegment.data} />);
61
+
62
+ expect(screen.getByText('Searching product documentation')).toBeInTheDocument();
63
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
64
+ expect(screen.queryByText('Remote read')).not.toBeInTheDocument();
65
+ });
66
+
67
+ it('keeps a typed local tool expandable when its body is absent', () => {
68
+ render(
69
+ <ToolExecutionDisplay
70
+ message={execution({
71
+ integratedToolType: 'OPENFRAME',
72
+ toolTitle: 'Refresh device inventory',
73
+ })}
74
+ />,
75
+ );
76
+
77
+ expect(screen.getByRole('button')).toBeInTheDocument();
78
+ });
79
+
80
+ it('keeps a backend-described remote read concise while running', () => {
81
+ render(
82
+ <ToolExecutionDisplay
83
+ message={execution({
84
+ integratedToolType: undefined,
85
+ toolTitle: 'Searching product documentation',
86
+ })}
87
+ />,
88
+ );
89
+
90
+ expect(screen.getByText('Searching product documentation')).toBeInTheDocument();
91
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
92
+ });
93
+ });
@@ -103,6 +103,7 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
103
103
  renderContextItem,
104
104
  renderMention,
105
105
  renderEntityCard,
106
+ refs,
106
107
  onAskSelect,
107
108
  NavLinkAnchor,
108
109
  ...props
@@ -127,6 +128,11 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
127
128
  // literal `[card://...]` URL.
128
129
  const hasMarkerSupport = !!renderEntityCard;
129
130
 
131
+ const refsByKey = useMemo(
132
+ () => new Map((refs ?? []).map(reference => [`${reference.type}:${reference.id}`, reference])),
133
+ [refs],
134
+ );
135
+
130
136
  const segments = useMemo(() => normalizeContent(content), [content]);
131
137
 
132
138
  const askRuns = useMemo(() => groupAskRuns(segments), [segments]);
@@ -180,7 +186,14 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
180
186
  // (and its open menu) survives across chunks. Invalidated per key when the
181
187
  // render fn identity changes.
182
188
  const renderedCardNodeCache = useRef(
183
- new Map<string, { render: ((ref: ChatRef) => React.ReactNode) | undefined; node: React.ReactNode }>(),
189
+ new Map<
190
+ string,
191
+ {
192
+ render: ((ref: ChatRef) => React.ReactNode) | undefined;
193
+ reference: ChatRef;
194
+ node: React.ReactNode;
195
+ }
196
+ >(),
184
197
  );
185
198
 
186
199
  /**
@@ -242,10 +255,17 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
242
255
  // menu / re-fetching) on every stream chunk. Also dedups the same
243
256
  // key emitted twice within one message.
244
257
  let entry = cache.get(key);
245
- if (!entry || entry.render !== render) {
246
- // The marker is the ONLY data on the wire: cards hydrate by id
247
- // from the host's per-object APIs, so a minimal {type, id}
248
- // descriptor is all the renderer needs to mount the loader.
258
+ const enrichedRef = refsByKey.get(key);
259
+ const refForRender: ChatRef = enrichedRef ?? {
260
+ type: cardType,
261
+ id: cardId,
262
+ title: cardId,
263
+ url: null,
264
+ };
265
+ if (!entry || entry.render !== render || entry.reference !== refForRender) {
266
+ // Prefer same-turn SOURCES metadata when available. Otherwise
267
+ // cards hydrate by id from the host's per-object APIs, so a
268
+ // minimal {type, id} descriptor still mounts the loader.
249
269
  // For types with nothing to fetch the host's render() returns
250
270
  // null and we fall through to the bare-cardId fallback in the
251
271
  // `<a card://…>` override below.
@@ -257,13 +277,7 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
257
277
  // the id rather than `undefined`) and `url` to null (matches
258
278
  // the no-link semantics fetch-mode cards rely on — they
259
279
  // resolve their own URL after fetch).
260
- const refForRender: ChatRef = {
261
- type: cardType,
262
- id: cardId,
263
- title: cardId,
264
- url: null,
265
- };
266
- entry = { render, node: render(refForRender) };
280
+ entry = { render, reference: refForRender, node: render(refForRender) };
267
281
  cache.set(key, entry);
268
282
  }
269
283
  const rendered = entry.node;
@@ -326,7 +340,7 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
326
340
  }
327
341
  });
328
342
  return { inlineByKey, partsBySegment, usedKeys };
329
- }, [hasMarkerSupport, renderEntityCard, segments]);
343
+ }, [hasMarkerSupport, refsByKey, renderEntityCard, segments]);
330
344
 
331
345
  // Drop cached nodes for markers no longer present so the cache can't grow
332
346
  // unbounded as a long message's markers change. Deliberately an EFFECT,
@@ -716,6 +730,7 @@ const MemoizedChatMessageEnhanced = memo(ChatMessageEnhanced, (prevProps, nextPr
716
730
  // equality holds across streaming chunks.
717
731
  prevProps.renderMention === nextProps.renderMention &&
718
732
  prevProps.renderEntityCard === nextProps.renderEntityCard &&
733
+ prevProps.refs === nextProps.refs &&
719
734
  // Same stability contract as the renderers above: hosts pass a `useCallback`
720
735
  // (EmbeddableChat passes its memoized `handleSend`), so this holds across
721
736
  // streaming chunks instead of re-rendering every ask card per chunk.
@@ -1005,6 +1005,7 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
1005
1005
  renderContextItem={renderContextItem}
1006
1006
  renderMention={renderMention}
1007
1007
  renderEntityCard={renderEntityCard}
1008
+ refs={message.refs}
1008
1009
  onAskSelect={index > lastUserMessageIndex ? onAskSelect : undefined}
1009
1010
  NavLinkAnchor={NavLinkAnchor}
1010
1011
  />
@@ -1579,6 +1579,10 @@ function EmbeddableChatInner({
1579
1579
  // explicitly — see `Message.hidden` and `chat-message-list`'s skip.
1580
1580
  ...(m.hidden ? { hidden: true } : {}),
1581
1581
  ...(m.scrollAnchor ? { scrollAnchor: m.scrollAnchor } : {}),
1582
+ // Preserve same-turn rich metadata. Source chips and entity-card
1583
+ // renderers consume it after this raw-to-Message projection.
1584
+ ...(m.sources && m.sources.length > 0 ? { sources: m.sources } : {}),
1585
+ ...(m.refs && m.refs.length > 0 ? { refs: m.refs } : {}),
1582
1586
  // Forward attached context items so the user bubble renders its chips.
1583
1587
  ...(m.contextItems && m.contextItems.length > 0 ? { contextItems: m.contextItems } : {}),
1584
1588
  };
@@ -1835,7 +1839,12 @@ function EmbeddableChatInner({
1835
1839
  if (chatLoading) return undefined;
1836
1840
  const sources = lastAssistantMsg?.sources;
1837
1841
  if (!sources || sources.length === 0) return undefined;
1838
- const content = lastAssistantMsg?.content || '';
1842
+ const content =
1843
+ lastAssistantMsg.content ||
1844
+ (lastAssistantMsg.segments ?? [])
1845
+ .filter(segment => segment.type === 'text')
1846
+ .map(segment => segment.text)
1847
+ .join('\n');
1839
1848
  const citationOrder = [...content.matchAll(/\[(\d+)\]/g)].map(m => parseInt(m[1], 10));
1840
1849
  const seenOrder = new Map<number, number>();
1841
1850
  citationOrder.forEach(idx => {
@@ -0,0 +1,74 @@
1
+ import React from 'react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import type { ChatRef } from '../../chat-ref.types';
4
+ import { BlockCard } from '../block-card';
5
+ import { ChatVideoEntityCard } from '../chat-video-entity-card';
6
+ import { renderChatInlineEntityCard } from '../dispatch';
7
+
8
+ describe('renderChatInlineEntityCard video refs', () => {
9
+ it.each([
10
+ {
11
+ id: 'MdFJNoJeqZQ',
12
+ url: 'https://www.youtube.com/watch?v=MdFJNoJeqZQ',
13
+ metadata: { youtubeUrl: 'MdFJNoJeqZQ' },
14
+ },
15
+ {
16
+ id: 'mux-9b6586b494',
17
+ url: 'https://stream.mux.com/playback-id.m3u8',
18
+ metadata: { videoUrl: 'https://stream.mux.com/playback-id.m3u8' },
19
+ },
20
+ {
21
+ id: 'mp4-1b2047dc1b',
22
+ url: 'https://cdn.example.test/product-overview.mp4',
23
+ metadata: { videoUrl: 'https://cdn.example.test/product-overview.mp4' },
24
+ },
25
+ ])('passes playable metadata for $id to the video card', ({ id, url, metadata }) => {
26
+ const ref: ChatRef = { type: 'video', id, title: 'Video', url, metadata };
27
+
28
+ const result = renderChatInlineEntityCard(ref);
29
+
30
+ expect(React.isValidElement(result)).toBe(true);
31
+ const block = result as React.ReactElement<React.ComponentProps<typeof BlockCard>>;
32
+ expect(block.type).toBe(BlockCard);
33
+ const video = block.props.children as React.ReactElement<React.ComponentProps<typeof ChatVideoEntityCard>>;
34
+ expect(video.type).toBe(ChatVideoEntityCard);
35
+ expect(video.props.chatRef.metadata).toMatchObject(metadata);
36
+ });
37
+
38
+ it('lets a validated top-level URL override conflicting route metadata', () => {
39
+ const ref: ChatRef = {
40
+ type: 'video',
41
+ id: 'mux-9b6586b494',
42
+ title: 'Video',
43
+ url: 'https://stream.mux.com/trusted-playback.m3u8',
44
+ metadata: {
45
+ videoUrl: 'https://cdn.example.test/untrusted.mp4',
46
+ youtubeUrl: 'untrustedId',
47
+ },
48
+ };
49
+
50
+ const result = renderChatInlineEntityCard(ref);
51
+ const block = result as React.ReactElement<React.ComponentProps<typeof BlockCard>>;
52
+ const video = block.props.children as React.ReactElement<React.ComponentProps<typeof ChatVideoEntityCard>>;
53
+
54
+ expect(video.props.chatRef.metadata).toEqual({
55
+ videoUrl: 'https://stream.mux.com/trusted-playback.m3u8',
56
+ });
57
+ });
58
+
59
+ it('keeps a direct-video URL direct when its query mentions a YouTube URL', () => {
60
+ const directUrl = 'https://cdn.example.test/product-overview.mp4?source=youtube.com/watch?v=MdFJNoJeqZQ';
61
+ const ref: ChatRef = {
62
+ type: 'video',
63
+ id: 'mp4-direct',
64
+ title: 'Product overview',
65
+ url: directUrl,
66
+ };
67
+
68
+ const result = renderChatInlineEntityCard(ref);
69
+ const block = result as React.ReactElement<React.ComponentProps<typeof BlockCard>>;
70
+ const video = block.props.children as React.ReactElement<React.ComponentProps<typeof ChatVideoEntityCard>>;
71
+
72
+ expect(video.props.chatRef.metadata).toEqual({ videoUrl: directUrl });
73
+ });
74
+ });
@@ -41,6 +41,7 @@ import {
41
41
  formatTimeWithTimezone,
42
42
  formatDurationFromRange,
43
43
  } from '../../../utils/format';
44
+ import { extractYouTubeId } from '../../features/video';
44
45
  import { MingoIcon } from '../../icons';
45
46
  import { ArrowRightUpIcon } from '../../icons-v2-generated/arrows/arrow-right-up-icon';
46
47
  import { ClickupLogoIcon } from '../../icons-v2-generated/brand-logos/clickup-logo-icon';
@@ -422,6 +423,15 @@ function hubspotStatusToVariant(status: string | undefined): MingoInfoCardStatus
422
423
  * which renders the marker as plain text (same anti-hallucination
423
424
  * posture as every other card type).
424
425
  */
426
+ function validatedHttpsUrl(value: unknown): string | null {
427
+ if (typeof value !== 'string') return null;
428
+ try {
429
+ return new URL(value).protocol === 'https:' ? value : null;
430
+ } catch {
431
+ return null;
432
+ }
433
+ }
434
+
425
435
  function decodeVideoMarkerId(id: string): { videoUrl?: string; youtubeUrl?: string } | null {
426
436
  // Prefix checks FIRST: an id like `mux-1234567` (11 chars) would also
427
437
  // satisfy the bare-YouTube-id pattern below.
@@ -439,8 +449,8 @@ function decodeVideoMarkerId(id: string): { videoUrl?: string; youtubeUrl?: stri
439
449
  const url = atob(b64);
440
450
  // https-only — the id is model-emitted text; never let a marker
441
451
  // smuggle a non-https src into a player.
442
- if (!/^https:\/\//.test(url)) return null;
443
- return { videoUrl: url };
452
+ const safeUrl = validatedHttpsUrl(url);
453
+ return safeUrl ? { videoUrl: safeUrl } : null;
444
454
  } catch {
445
455
  return null;
446
456
  }
@@ -449,6 +459,29 @@ function decodeVideoMarkerId(id: string): { videoUrl?: string; youtubeUrl?: stri
449
459
  return null;
450
460
  }
451
461
 
462
+ function validatedVideoMetadata(chatRef: ChatRef): Record<string, unknown> | null {
463
+ const input = chatRef.metadata ?? {};
464
+ const metadata: Record<string, unknown> = {};
465
+ for (const key of ['videoUrl', 'highlightVideoUrl', 'videoPoster', 'highlightVideoPoster'] as const) {
466
+ const url = validatedHttpsUrl(input[key]);
467
+ if (url) metadata[key] = url;
468
+ }
469
+ const youtubeUrl = input.youtubeUrl;
470
+ if (typeof youtubeUrl === 'string' && /^[A-Za-z0-9_-]{11}$/.test(youtubeUrl)) {
471
+ metadata.youtubeUrl = youtubeUrl;
472
+ }
473
+
474
+ const safeUrl = validatedHttpsUrl(chatRef.url);
475
+ if (safeUrl) {
476
+ const youtubeId = extractYouTubeId(safeUrl);
477
+ delete metadata.videoUrl;
478
+ delete metadata.youtubeUrl;
479
+ metadata[youtubeId ? 'youtubeUrl' : 'videoUrl'] = youtubeId ?? safeUrl;
480
+ }
481
+
482
+ return Object.keys(metadata).length > 0 ? metadata : null;
483
+ }
484
+
452
485
  /** Read hero-video metadata off a FETCHED item — the API-driven
453
486
  * replacement for the retired refs-frame `metadata.videoUrl` check. Two item
454
487
  * shapes: ChatRef-shaped items (per-object hydration routes) carry the
@@ -1983,24 +2016,28 @@ export function renderChatInlineEntityCard(
1983
2016
  />
1984
2017
  );
1985
2018
 
1986
- // Embedded videos are SELF-DESCRIBING: the marker id alone recovers the
1987
- // playable URL (`decodeVideoMarkerId`), so the player renders on ANY
1988
- // transport with zero refs/fetch. Undecodable ids (legacy sha1 hashes,
1989
- // hallucinations) fall through to the loader → plain-text degrade.
2019
+ // SOURCES metadata may enrich an opaque video marker with its playable URL.
2020
+ // Self-describing marker ids remain transport-independent through
2021
+ // `decodeVideoMarkerId`; undecodable ids without matching metadata fall
2022
+ // through to the loader and degrade to plain text.
1990
2023
  // Hero videos on FETCHED entity types (webinars, releases, …) are
1991
2024
  // handled post-fetch inside ChatCardLoader (`itemVideoMetadata`) — the
1992
- // marker's ref plays no part in either path.
2025
+ // marker metadata is not required for that post-fetch path.
1993
2026
  if (chatRef.type === 'video') {
2027
+ const enrichedMetadata = validatedVideoMetadata(chatRef);
1994
2028
  const decoded = decodeVideoMarkerId(chatRef.id);
1995
- if (decoded) {
2029
+ const playableMetadata = enrichedMetadata ?? decoded;
2030
+ if (playableMetadata) {
1996
2031
  const displayRef: ChatRef = {
1997
2032
  ...chatRef,
1998
2033
  title: chatRef.title && chatRef.title !== chatRef.id ? chatRef.title : 'Video',
1999
2034
  url:
2000
2035
  chatRef.url ??
2001
- decoded.videoUrl ??
2002
- (decoded.youtubeUrl ? `https://www.youtube.com/watch?v=${decoded.youtubeUrl}` : null),
2003
- metadata: { ...(chatRef.metadata ?? {}), ...decoded },
2036
+ (typeof playableMetadata.videoUrl === 'string' ? playableMetadata.videoUrl : null) ??
2037
+ (typeof playableMetadata.youtubeUrl === 'string'
2038
+ ? `https://www.youtube.com/watch?v=${playableMetadata.youtubeUrl}`
2039
+ : null),
2040
+ metadata: playableMetadata,
2004
2041
  };
2005
2042
  return (
2006
2043
  <BlockCard inline={<ChatInlineVideoPill chatRef={displayRef} />}>
@@ -61,6 +61,7 @@ import type {
61
61
  MessageSegment,
62
62
  NatsMessageType,
63
63
  ChatApprovalStatus,
64
+ ChatSource,
64
65
  } from '../types';
65
66
  import type { DialogItem } from '../types/component.types';
66
67
  import type {
@@ -352,6 +353,8 @@ export function mapProcessedToUnified(
352
353
  role: 'user' | 'assistant' | 'error';
353
354
  content: string | MessageSegment[];
354
355
  name?: string;
356
+ sources?: ChatSource[];
357
+ refs?: ChatRef[];
355
358
  }>,
356
359
  ): UnifiedChatMessage[] {
357
360
  const out: UnifiedChatMessage[] = [];
@@ -363,12 +366,16 @@ export function mapProcessedToUnified(
363
366
  role: m.role,
364
367
  content: '',
365
368
  segments: m.content,
369
+ ...(m.sources ? { sources: m.sources } : {}),
370
+ ...(m.refs ? { refs: m.refs } : {}),
366
371
  });
367
372
  } else {
368
373
  out.push({
369
374
  id: m.id,
370
375
  role: m.role,
371
376
  content: m.content,
377
+ ...(m.sources ? { sources: m.sources } : {}),
378
+ ...(m.refs ? { refs: m.refs } : {}),
372
379
  });
373
380
  }
374
381
  }
@@ -63,7 +63,7 @@ import type { ChatDialogStore } from '../stream/chat-dialog-store';
63
63
  import type { ChatStreamReducerOptions } from '../stream/chat-stream-reducer';
64
64
  import { useChatStreamReducer } from '../stream/use-chat-stream-reducer';
65
65
  import type { DialogItem } from '../types/component.types';
66
- import type { Message, MessageSegment } from '../types/message.types';
66
+ import type { ChatSource, Message, MessageSegment } from '../types/message.types';
67
67
  import type {
68
68
  UnifiedChatState,
69
69
  UnifiedChatMessage,
@@ -81,6 +81,7 @@ import { useSlashCommandRegistry, type SlashCommandSummary } from './use-slash-c
81
81
  // Canonical home of the per-turn meta row moved to the stream module in
82
82
  // Phase 3; re-exported here to keep the legacy import path stable.
83
83
  export type { ChatTurnMeta } from '../stream/chat-stream-reducer';
84
+ export type { ChatSource } from '../types/message.types';
84
85
 
85
86
  // ─── Stable no-op references for the Guide-mode dialog-management stubs ──
86
87
  // Plain module-scope constants so the adapter's return identity stays
@@ -128,34 +129,6 @@ const noopBumpMetaTick = (): void => {};
128
129
  * hub-side platform-utils, not in lib). */
129
130
  export type DocSource = string;
130
131
 
131
- export interface ChatSource {
132
- index: number;
133
- name: string;
134
- path: string;
135
- documentType: string;
136
- externalUrl?: string;
137
- /** Platform that owns the destination at `externalUrl`. */
138
- targetPlatform?: string | null;
139
- /** Primary-key value for single-row chips. */
140
- id?: string;
141
- /** Per-row items for grouped chips. */
142
- items?: Array<{
143
- id: string;
144
- documentType: string;
145
- name: string;
146
- externalUrl?: string;
147
- targetPlatform?: string | null;
148
- /** In-app doc-tree path for markdown / data-room-doc rows so the
149
- * grouped chip's anchor can trigger an in-page doc-tree swap via
150
- * `handleChatNavClick` (parity with single-row chips + cards). */
151
- path?: string | null;
152
- }>;
153
- /** RagTableConfig.id for this source. */
154
- sourceRepo?: string;
155
- /** Optional display label override returned by the chat API. */
156
- label?: string;
157
- }
158
-
159
132
  export interface DocChatMessage {
160
133
  id: string;
161
134
  role: 'user' | 'assistant';
@@ -1289,3 +1289,59 @@ describe('createChatStreamReducer — ask cards', () => {
1289
1289
  expect(last.segments?.map(s => s.type)).toEqual(['ask', 'ask']);
1290
1290
  });
1291
1291
  });
1292
+
1293
+ describe('createChatStreamReducer — Mingo source metadata', () => {
1294
+ const sources = [
1295
+ {
1296
+ index: 1,
1297
+ name: 'Enroll devices',
1298
+ path: 'docs/enroll.md',
1299
+ documentType: 'markdown',
1300
+ },
1301
+ ];
1302
+
1303
+ it('attaches metadata received before text to the same assistant turn', () => {
1304
+ const reducer = createChatStreamReducer({ transport: 'nats' });
1305
+ const refs = [
1306
+ {
1307
+ type: 'video',
1308
+ id: 'mux-9b6586b494',
1309
+ title: 'Community demo',
1310
+ url: 'https://stream.mux.com/playback-id.m3u8',
1311
+ metadata: { videoUrl: 'https://stream.mux.com/playback-id.m3u8' },
1312
+ },
1313
+ ];
1314
+
1315
+ reducer.apply({ type: 'turn-start', seq: 1 });
1316
+ reducer.apply({ type: 'sources', sources, refs, seq: 2 });
1317
+ reducer.apply({ type: 'text-delta', text: 'Follow the guide [1].', seq: 3 });
1318
+
1319
+ expect(reducer.state.messages).toHaveLength(1);
1320
+ expect(reducer.state.messages[0]).toMatchObject({ sources, refs });
1321
+ });
1322
+
1323
+ it('deduplicates cumulative metadata and does not leak it into the next turn', () => {
1324
+ const reducer = createChatStreamReducer({ transport: 'nats' });
1325
+
1326
+ reducer.apply({ type: 'turn-start', seq: 1 });
1327
+ reducer.apply({ type: 'sources', sources, seq: 2 });
1328
+ reducer.apply({
1329
+ type: 'sources',
1330
+ sources: [
1331
+ { ...sources[0], name: 'Duplicate' },
1332
+ { index: 2, name: 'Install agent', path: 'docs/install.md', documentType: 'markdown' },
1333
+ ],
1334
+ seq: 3,
1335
+ });
1336
+ reducer.apply({ type: 'text-delta', text: 'First answer [1] [2].', seq: 4 });
1337
+ reducer.apply({ type: 'turn-end', seq: 5 });
1338
+ reducer.apply({ type: 'turn-start', seq: 6 });
1339
+ reducer.apply({ type: 'text-delta', text: 'Second answer.', seq: 7 });
1340
+
1341
+ expect(reducer.state.messages[0].sources).toEqual([
1342
+ sources[0],
1343
+ { index: 2, name: 'Install agent', path: 'docs/install.md', documentType: 'markdown' },
1344
+ ]);
1345
+ expect(reducer.state.messages[1].sources).toBeUndefined();
1346
+ });
1347
+ });
@@ -60,6 +60,7 @@ import {
60
60
  type ParticipantEvent,
61
61
  type UsageEvent,
62
62
  } from '../../../chat-protocol/events';
63
+ import type { ChatRef } from '../chat-ref.types';
63
64
  import type {
64
65
  ApprovalBatchSegment,
65
66
  ApprovalRequestSegment,
@@ -70,6 +71,7 @@ import type {
70
71
  ToolExecutionSegment,
71
72
  ExecutingToolState,
72
73
  } from '../types';
74
+ import type { ChatSource } from '../types/message.types';
73
75
  import type { PendingApproval } from '../types/processing.types';
74
76
  import type {
75
77
  DialogTokenUsage,
@@ -78,6 +80,7 @@ import type {
78
80
  UnifiedUsageBreakdown,
79
81
  } from '../types/unified-chat-state.types';
80
82
  import { approvalDisplaysInline } from '../utils/approval-display';
83
+ import { mergeChatRefs, mergeChatSources } from '../utils/chat-source-metadata';
81
84
  import {
82
85
  type MessageSegmentAccumulator,
83
86
  createMessageSegmentAccumulator,
@@ -676,6 +679,9 @@ export function createChatStreamReducer(options: ChatStreamReducerOptions = {}):
676
679
  let sendCount = 0;
677
680
  let sseCurrentText = '';
678
681
 
682
+ let pendingSources: ChatSource[] | undefined;
683
+ let pendingRefs: ChatRef[] | undefined;
684
+
679
685
  // ── Snapshot cache ─────────────────────────────────────────────────────
680
686
  let stateCache: ChatReducerState | null = null;
681
687
  function invalidate(): void {
@@ -773,6 +779,18 @@ export function createChatStreamReducer(options: ChatStreamReducerOptions = {}):
773
779
  setMessagesInternal(stampTrailingAssistantSeq(updateTrailingAssistant(messages, segments), seq));
774
780
  }
775
781
 
782
+ function attachPendingSourceMetadata(): void {
783
+ if (!pendingSources && !pendingRefs) return;
784
+ const last = messages[messages.length - 1];
785
+ if (!last || last.role !== 'assistant') return;
786
+ const next = {
787
+ ...last,
788
+ ...(pendingSources ? { sources: pendingSources } : {}),
789
+ ...(pendingRefs ? { refs: pendingRefs } : {}),
790
+ };
791
+ setMessagesInternal([...messages.slice(0, -1), next]);
792
+ }
793
+
776
794
  function applyStreamStartToState(): void {
777
795
  setPhaseInternal('streaming');
778
796
  // A new stream must never overwrite a COMPLETED trailing assistant
@@ -966,6 +984,8 @@ export function createChatStreamReducer(options: ChatStreamReducerOptions = {}):
966
984
 
967
985
  switch (event.type) {
968
986
  case 'turn-start': {
987
+ pendingSources = undefined;
988
+ pendingRefs = undefined;
969
989
  isInStream = true;
970
990
  hasEverStreamed = true;
971
991
  turnStartedAt = Date.now();
@@ -981,6 +1001,15 @@ export function createChatStreamReducer(options: ChatStreamReducerOptions = {}):
981
1001
  emit('onStreamEnd');
982
1002
  setPhaseInternal('idle');
983
1003
  accumulator.resetSegments();
1004
+ pendingSources = undefined;
1005
+ pendingRefs = undefined;
1006
+ break;
1007
+ }
1008
+
1009
+ case 'sources': {
1010
+ pendingSources = mergeChatSources(pendingSources, event.sources);
1011
+ if (event.refs) pendingRefs = mergeChatRefs(pendingRefs, event.refs);
1012
+ attachPendingSourceMetadata();
984
1013
  break;
985
1014
  }
986
1015
 
@@ -1017,10 +1046,12 @@ export function createChatStreamReducer(options: ChatStreamReducerOptions = {}):
1017
1046
  if (isInStream || !hasEverStreamed) {
1018
1047
  emitSegments(segments);
1019
1048
  applySegmentsToState(segments, withSeqMeta(undefined));
1049
+ attachPendingSourceMetadata();
1020
1050
  } else {
1021
1051
  const delta: MessageSegment[] = [{ type: kind, text: event.text }];
1022
1052
  emitSegments(delta, { append: true });
1023
1053
  applySegmentsToState(delta, withSeqMeta({ append: true }));
1054
+ attachPendingSourceMetadata();
1024
1055
  }
1025
1056
  break;
1026
1057
  }
@@ -54,6 +54,9 @@ const ToolExecutionDisplay = forwardRef<HTMLDivElement, ToolExecutionDisplayProp
54
54
 
55
55
  const hasResult = isExecuted && typeof message.result === 'string' && message.result.length > 0;
56
56
  const hasBody = argEntries.length > 0 || hasResult || isExecuting;
57
+ const isConciseRemoteRead =
58
+ !message.integratedToolType && !!message.toolTitle?.trim() && argEntries.length === 0 && !hasResult;
59
+ const isStaticRow = isClientView || isConciseRemoteRead;
57
60
 
58
61
  const renderStatusIcon = () => {
59
62
  if (isExecuting) return <DotsLoaderIcon size={16} className="text-ods-text-secondary" />;
@@ -82,7 +85,7 @@ const ToolExecutionDisplay = forwardRef<HTMLDivElement, ToolExecutionDisplayProp
82
85
  {previewText}
83
86
  </div>
84
87
  <div className="flex h-5 w-5 shrink-0 items-center justify-center">{renderStatusIcon()}</div>
85
- {!isClientView && (
88
+ {!isStaticRow && (
86
89
  <div className="flex h-5 w-5 shrink-0 items-center justify-center">
87
90
  <ExpandChevron expanded={expanded} />
88
91
  </div>
@@ -101,7 +104,7 @@ const ToolExecutionDisplay = forwardRef<HTMLDivElement, ToolExecutionDisplayProp
101
104
  )}
102
105
  {...props}
103
106
  >
104
- {isClientView ? (
107
+ {isStaticRow ? (
105
108
  // Client (Fae end-user): static, non-expandable row — no chevron, no
106
109
  // body. Just the explanation + status; command/args/result are hidden.
107
110
  <div className="flex w-full items-start gap-[var(--spacing-system-xs)] p-[var(--spacing-system-s)] text-left">
@@ -195,6 +195,8 @@ export interface ChatMessageEnhancedProps extends Omit<HTMLAttributes<HTMLDivEle
195
195
  * back to plain text title-only.
196
196
  */
197
197
  renderEntityCard?: (reference: ChatRef) => ReactNode;
198
+ /** Enriched references associated with this assistant answer. */
199
+ refs?: ChatRef[];
198
200
  /**
199
201
  * Host-provided anchor component for markdown links. When supplied, the
200
202
  * `<a>` override in the markdown renderer delegates to this component