@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
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import {
4
+ Fragment,
4
5
  useRef,
5
6
  useState,
6
7
  useCallback,
@@ -138,6 +139,7 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
138
139
  renderContextItem,
139
140
  renderMention,
140
141
  onAskSelect,
142
+ renderAfterMessage,
141
143
  NavLinkAnchor,
142
144
  ...props
143
145
  },
@@ -986,28 +988,31 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
986
988
  (!Array.isArray(message.content) || message.content.every(s => s.type === 'text'));
987
989
  if (isEmptyPendingTurn) return null;
988
990
  return (
989
- <ChatMessageEnhanced
990
- key={message.id}
991
- ref={getRegisterMessageEl(message.id)}
992
- role={message.role}
993
- name={message.name}
994
- content={message.content}
995
- timestamp={message.timestamp}
996
- isTyping={index === messages.length - 1 && isTyping && message.role === 'assistant'}
997
- avatar={showAvatars ? message.avatar : null}
998
- showAvatar={showAvatars}
999
- assistantType={message.assistantType || assistantType}
1000
- approvalVariant={approvalVariant}
1001
- authorType={message.authorType}
1002
- assistantIcon={message.role !== 'user' ? assistantIcon : undefined}
1003
- contextItems={message.contextItems}
1004
- resolveContextIcon={resolveContextIcon}
1005
- renderContextItem={renderContextItem}
1006
- renderMention={renderMention}
1007
- renderEntityCard={renderEntityCard}
1008
- onAskSelect={index > lastUserMessageIndex ? onAskSelect : undefined}
1009
- NavLinkAnchor={NavLinkAnchor}
1010
- />
991
+ <Fragment key={message.id}>
992
+ <ChatMessageEnhanced
993
+ ref={getRegisterMessageEl(message.id)}
994
+ role={message.role}
995
+ name={message.name}
996
+ content={message.content}
997
+ timestamp={message.timestamp}
998
+ isTyping={index === messages.length - 1 && isTyping && message.role === 'assistant'}
999
+ avatar={showAvatars ? message.avatar : null}
1000
+ showAvatar={showAvatars}
1001
+ assistantType={message.assistantType || assistantType}
1002
+ approvalVariant={approvalVariant}
1003
+ authorType={message.authorType}
1004
+ assistantIcon={message.role !== 'user' ? assistantIcon : undefined}
1005
+ contextItems={message.contextItems}
1006
+ resolveContextIcon={resolveContextIcon}
1007
+ renderContextItem={renderContextItem}
1008
+ renderMention={renderMention}
1009
+ renderEntityCard={renderEntityCard}
1010
+ refs={message.refs}
1011
+ onAskSelect={index > lastUserMessageIndex ? onAskSelect : undefined}
1012
+ NavLinkAnchor={NavLinkAnchor}
1013
+ />
1014
+ {renderAfterMessage?.(message, index)}
1015
+ </Fragment>
1011
1016
  );
1012
1017
  })}
1013
1018
  </div>
@@ -808,13 +808,14 @@ function SourceChips({
808
808
  const [expanded, setExpanded] = useState(false);
809
809
 
810
810
  if (cited.length === 0) {
811
- const fallback = uncited.slice(0, FALLBACK_TOP_RETRIEVED);
812
- if (fallback.length === 0) return null;
811
+ const visible = expanded ? uncited : uncited.slice(0, FALLBACK_TOP_RETRIEVED);
812
+ const hiddenCount = Math.max(0, uncited.length - FALLBACK_TOP_RETRIEVED);
813
+ if (visible.length === 0) return null;
813
814
  return (
814
815
  <div className="mt-2 flex flex-col gap-1.5 border-t border-ods-border pt-2">
815
816
  <span className="uppercase text-ods-text-muted text-h6">Top retrieved sources</span>
816
- <div className="flex flex-wrap gap-1.5">
817
- {fallback.map(src => (
817
+ <div className={`flex flex-wrap gap-1.5 ${expanded ? 'max-h-[200px] overflow-y-auto overscroll-contain' : ''}`}>
818
+ {visible.map(src => (
818
819
  <SourceChip
819
820
  key={src.index}
820
821
  src={src}
@@ -824,6 +825,16 @@ function SourceChips({
824
825
  onDiscuss={onDiscuss}
825
826
  />
826
827
  ))}
828
+ {hiddenCount > 0 && (
829
+ <button
830
+ onClick={() => setExpanded(!expanded)}
831
+ className="inline-flex cursor-pointer items-center gap-1 rounded border border-ods-accent bg-ods-card px-2 py-0.5 text-ods-accent transition-colors text-h6 hover:bg-ods-accent/10"
832
+ aria-expanded={expanded}
833
+ aria-label={expanded ? 'Show fewer sources' : `Show ${hiddenCount} additional retrieved sources`}
834
+ >
835
+ {expanded ? 'Show less' : `+${hiddenCount} more retrieved ${hiddenCount === 1 ? 'source' : 'sources'}`}
836
+ </button>
837
+ )}
827
838
  </div>
828
839
  </div>
829
840
  );
@@ -1579,6 +1590,10 @@ function EmbeddableChatInner({
1579
1590
  // explicitly — see `Message.hidden` and `chat-message-list`'s skip.
1580
1591
  ...(m.hidden ? { hidden: true } : {}),
1581
1592
  ...(m.scrollAnchor ? { scrollAnchor: m.scrollAnchor } : {}),
1593
+ // Preserve same-turn rich metadata. Source chips and entity-card
1594
+ // renderers consume it after this raw-to-Message projection.
1595
+ ...(m.sources && m.sources.length > 0 ? { sources: m.sources } : {}),
1596
+ ...(m.refs && m.refs.length > 0 ? { refs: m.refs } : {}),
1582
1597
  // Forward attached context items so the user bubble renders its chips.
1583
1598
  ...(m.contextItems && m.contextItems.length > 0 ? { contextItems: m.contextItems } : {}),
1584
1599
  };
@@ -1829,24 +1844,41 @@ function EmbeddableChatInner({
1829
1844
  return out;
1830
1845
  }, [commandsById, enabledSet]);
1831
1846
 
1832
- // Find sources for the last assistant message; split into cited / uncited.
1833
- const lastAssistantMsg = [...rawMessages].reverse().find(m => m.role === 'assistant');
1834
- const lastSources = useMemo(() => {
1835
- if (chatLoading) return undefined;
1836
- const sources = lastAssistantMsg?.sources;
1837
- if (!sources || sources.length === 0) return undefined;
1838
- const content = lastAssistantMsg?.content || '';
1839
- const citationOrder = [...content.matchAll(/\[(\d+)\]/g)].map(m => parseInt(m[1], 10));
1840
- const seenOrder = new Map<number, number>();
1841
- citationOrder.forEach(idx => {
1842
- if (!seenOrder.has(idx)) seenOrder.set(idx, seenOrder.size);
1843
- });
1844
- const cited = sources
1845
- .filter(s => seenOrder.has(s.index))
1846
- .sort((a, b) => (seenOrder.get(a.index) ?? 0) - (seenOrder.get(b.index) ?? 0));
1847
- const uncited = sources.filter(s => !seenOrder.has(s.index));
1848
- return { cited, uncited };
1849
- }, [lastAssistantMsg, chatLoading]);
1847
+ const renderSourcesAfterMessage = useCallback(
1848
+ (message: Message, index: number) => {
1849
+ if (message.role !== 'assistant' || !message.sources?.length) return null;
1850
+ if (chatLoading && index === messages.length - 1) return null;
1851
+ const content =
1852
+ typeof message.content === 'string'
1853
+ ? message.content
1854
+ : message.content
1855
+ .filter(segment => segment.type === 'text')
1856
+ .map(segment => segment.text)
1857
+ .join('\n');
1858
+ const citationOrder = [...content.matchAll(/\[(\d+)\]/g)].map(m => parseInt(m[1], 10));
1859
+ const seenOrder = new Map<number, number>();
1860
+ citationOrder.forEach(idx => {
1861
+ if (!seenOrder.has(idx)) seenOrder.set(idx, seenOrder.size);
1862
+ });
1863
+ const cited = message.sources
1864
+ .filter(s => seenOrder.has(s.index))
1865
+ .sort((a, b) => (seenOrder.get(a.index) ?? 0) - (seenOrder.get(b.index) ?? 0));
1866
+ const uncited = message.sources.filter(s => !seenOrder.has(s.index));
1867
+ return (
1868
+ <div className="flex-shrink-0 pb-2">
1869
+ <SourceChips
1870
+ cited={cited}
1871
+ uncited={uncited}
1872
+ baseRoute={resolvedBaseRoute}
1873
+ chipBasePlatform={chipBasePlatform}
1874
+ onClose={handleNavigationClose}
1875
+ onDiscuss={discussRef}
1876
+ />
1877
+ </div>
1878
+ );
1879
+ },
1880
+ [chatLoading, messages.length, resolvedBaseRoute, chipBasePlatform, handleNavigationClose, discussRef],
1881
+ );
1850
1882
 
1851
1883
  // Host node for in-panel Radix portals (see the body wrapper below).
1852
1884
  const [portalHost, setPortalHost] = useState<HTMLDivElement | null>(null);
@@ -2390,6 +2422,7 @@ function EmbeddableChatInner({
2390
2422
  resolveContextIcon={resolveContextIcon}
2391
2423
  renderContextItem={renderContextItem}
2392
2424
  renderMention={renderMention}
2425
+ renderAfterMessage={renderSourcesAfterMessage}
2393
2426
  // Gated on `chatLoading` for the same reason the composer
2394
2427
  // is: no second send while a turn is in flight. Passive
2395
2428
  // demo hosts (previewMode) stay read-only.
@@ -2427,20 +2460,6 @@ function EmbeddableChatInner({
2427
2460
  onLoadMore={loadMoreMessages}
2428
2461
  />
2429
2462
  )}
2430
- {lastSources &&
2431
- (lastSources.cited.length > 0 || lastSources.uncited.length > 0) &&
2432
- !chatLoading && (
2433
- <div className="flex-shrink-0 pb-2">
2434
- <SourceChips
2435
- cited={lastSources.cited}
2436
- uncited={lastSources.uncited}
2437
- baseRoute={resolvedBaseRoute}
2438
- chipBasePlatform={chipBasePlatform}
2439
- onClose={handleNavigationClose}
2440
- onDiscuss={discussRef}
2441
- />
2442
- </div>
2443
- )}
2444
2463
  </div>
2445
2464
  ) : activeMode === 'mingo' ? (
2446
2465
  /* Figma node 7532:222444 — default (Mingo-mode) empty state:
@@ -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
+ });
@@ -30,6 +30,7 @@
30
30
  */
31
31
 
32
32
  import React, { type ReactNode } from 'react';
33
+ import { isSourceMetadataCardRef } from '../../../chat-protocol/source-metadata';
33
34
  import { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context';
34
35
  import Image from '../../../embed-shims/next-image';
35
36
  import { useRouter } from '../../../embed-shims/next-navigation';
@@ -41,6 +42,7 @@ import {
41
42
  formatTimeWithTimezone,
42
43
  formatDurationFromRange,
43
44
  } from '../../../utils/format';
45
+ import { extractYouTubeId } from '../../features/video';
44
46
  import { MingoIcon } from '../../icons';
45
47
  import { ArrowRightUpIcon } from '../../icons-v2-generated/arrows/arrow-right-up-icon';
46
48
  import { ClickupLogoIcon } from '../../icons-v2-generated/brand-logos/clickup-logo-icon';
@@ -422,6 +424,15 @@ function hubspotStatusToVariant(status: string | undefined): MingoInfoCardStatus
422
424
  * which renders the marker as plain text (same anti-hallucination
423
425
  * posture as every other card type).
424
426
  */
427
+ function validatedHttpsUrl(value: unknown): string | null {
428
+ if (typeof value !== 'string') return null;
429
+ try {
430
+ return new URL(value).protocol === 'https:' ? value : null;
431
+ } catch {
432
+ return null;
433
+ }
434
+ }
435
+
425
436
  function decodeVideoMarkerId(id: string): { videoUrl?: string; youtubeUrl?: string } | null {
426
437
  // Prefix checks FIRST: an id like `mux-1234567` (11 chars) would also
427
438
  // satisfy the bare-YouTube-id pattern below.
@@ -439,8 +450,8 @@ function decodeVideoMarkerId(id: string): { videoUrl?: string; youtubeUrl?: stri
439
450
  const url = atob(b64);
440
451
  // https-only — the id is model-emitted text; never let a marker
441
452
  // smuggle a non-https src into a player.
442
- if (!/^https:\/\//.test(url)) return null;
443
- return { videoUrl: url };
453
+ const safeUrl = validatedHttpsUrl(url);
454
+ return safeUrl ? { videoUrl: safeUrl } : null;
444
455
  } catch {
445
456
  return null;
446
457
  }
@@ -449,6 +460,29 @@ function decodeVideoMarkerId(id: string): { videoUrl?: string; youtubeUrl?: stri
449
460
  return null;
450
461
  }
451
462
 
463
+ function validatedVideoMetadata(chatRef: ChatRef): Record<string, unknown> | null {
464
+ const input = chatRef.metadata ?? {};
465
+ const metadata: Record<string, unknown> = {};
466
+ for (const key of ['videoUrl', 'highlightVideoUrl', 'videoPoster', 'highlightVideoPoster'] as const) {
467
+ const url = validatedHttpsUrl(input[key]);
468
+ if (url) metadata[key] = url;
469
+ }
470
+ const youtubeUrl = input.youtubeUrl;
471
+ if (typeof youtubeUrl === 'string' && /^[A-Za-z0-9_-]{11}$/.test(youtubeUrl)) {
472
+ metadata.youtubeUrl = youtubeUrl;
473
+ }
474
+
475
+ const safeUrl = validatedHttpsUrl(chatRef.url);
476
+ if (safeUrl) {
477
+ const youtubeId = extractYouTubeId(safeUrl);
478
+ delete metadata.videoUrl;
479
+ delete metadata.youtubeUrl;
480
+ metadata[youtubeId ? 'youtubeUrl' : 'videoUrl'] = youtubeId ?? safeUrl;
481
+ }
482
+
483
+ return Object.keys(metadata).length > 0 ? metadata : null;
484
+ }
485
+
452
486
  /** Read hero-video metadata off a FETCHED item — the API-driven
453
487
  * replacement for the retired refs-frame `metadata.videoUrl` check. Two item
454
488
  * shapes: ChatRef-shaped items (per-object hydration routes) carry the
@@ -1228,6 +1262,10 @@ interface ChatCardRegistryEntry {
1228
1262
  * point — a host that re-homes a type must be able to say so even when
1229
1263
  * the row already carries the content host's own url. */
1230
1264
  noComposedHref?: boolean;
1265
+ /** Render a document card from same-turn SOURCES metadata when its optional
1266
+ * hydration endpoint has no row. Only doc refs opt in: their source record
1267
+ * already carries the complete title/path/navigation fallback. */
1268
+ renderFromSourceMetadata?: boolean;
1231
1269
  }
1232
1270
 
1233
1271
  /**
@@ -1250,6 +1288,7 @@ function refHydratedEntry(
1250
1288
  docType: string,
1251
1289
  label: string,
1252
1290
  renderRef: (displayRef: ChatRef, opts: ChatCardRenderOptions) => React.ReactNode,
1291
+ renderFromSourceMetadata = false,
1253
1292
  ): ChatCardRegistryEntry {
1254
1293
  return {
1255
1294
  label,
@@ -1259,6 +1298,19 @@ function refHydratedEntry(
1259
1298
  fallbackHref: (item: { url?: string | null }) => item?.url ?? null,
1260
1299
  skeleton: () => <MingoInfoCardSkeleton />,
1261
1300
  render: (item, chatRef, opts) => renderRef(fetchedItemDisplayRef(item, chatRef), opts),
1301
+ renderFromSourceMetadata,
1302
+ };
1303
+ }
1304
+
1305
+ function sourceMetadataCardItem(chatRef: ChatRef): ChatCardItem {
1306
+ return {
1307
+ type: chatRef.type,
1308
+ id: chatRef.id,
1309
+ title: chatRef.title,
1310
+ url: chatRef.url,
1311
+ targetPlatform: chatRef.targetPlatform,
1312
+ sourceRepo: chatRef.sourceRepo,
1313
+ metadata: chatRef.metadata,
1262
1314
  };
1263
1315
  }
1264
1316
 
@@ -1454,12 +1506,18 @@ const CHAT_CARD_REGISTRY: Record<string, ChatCardRegistryEntry> = {
1454
1506
  hubspot_ticket_self: refHydratedEntry('hubspot_ticket_self', 'HubSpot ticket (self)', (displayRef, opts) => (
1455
1507
  <HubspotTicketChatCard chatRef={displayRef} isNewTab={opts.isNewTab} discuss={opts.discuss} />
1456
1508
  )),
1457
- data_room_doc: refHydratedEntry('data_room_doc', 'Data-room doc', (displayRef, opts) => (
1458
- <DataRoomDocChatCard chatRef={displayRef} isNewTab={opts.isNewTab} discuss={opts.discuss} />
1459
- )),
1460
- markdown: refHydratedEntry('markdown', 'Doc page (markdown)', (displayRef, opts) => (
1461
- <DataRoomDocChatCard chatRef={displayRef} isNewTab={opts.isNewTab} discuss={opts.discuss} />
1462
- )),
1509
+ data_room_doc: refHydratedEntry(
1510
+ 'data_room_doc',
1511
+ 'Data-room doc',
1512
+ (displayRef, opts) => <DataRoomDocChatCard chatRef={displayRef} isNewTab={opts.isNewTab} discuss={opts.discuss} />,
1513
+ true,
1514
+ ),
1515
+ markdown: refHydratedEntry(
1516
+ 'markdown',
1517
+ 'Doc page (markdown)',
1518
+ (displayRef, opts) => <DataRoomDocChatCard chatRef={displayRef} isNewTab={opts.isNewTab} discuss={opts.discuss} />,
1519
+ true,
1520
+ ),
1463
1521
  // Body-synthesized video ids (`shortVideoId` content hashes / YouTube
1464
1522
  // ids) have no backing table row to fetch — ref-only by nature.
1465
1523
  video: {
@@ -1902,6 +1960,13 @@ export function ChatCardLoader({
1902
1960
  }
1903
1961
  if (isLoading) return <>{fetchEntry.skeleton?.() ?? null}</>;
1904
1962
  if (!item) {
1963
+ const hasSourceMetadata =
1964
+ fetchEntry.renderFromSourceMetadata &&
1965
+ isSourceMetadataCardRef(chatRef) &&
1966
+ typeof finalChatRef.metadata?.path === 'string';
1967
+ if (hasSourceMetadata) {
1968
+ return finish(fetchEntry.render(sourceMetadataCardItem(finalChatRef), finalChatRef, renderOpts));
1969
+ }
1905
1970
  // FAIL LOUD (explicit product decision 2026-08-13): a card that
1906
1971
  // cannot hydrate renders a VISIBLE error card — silent removal made
1907
1972
  // broken answers look fine. Three distinguishable states:
@@ -1983,24 +2048,28 @@ export function renderChatInlineEntityCard(
1983
2048
  />
1984
2049
  );
1985
2050
 
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.
2051
+ // SOURCES metadata may enrich an opaque video marker with its playable URL.
2052
+ // Self-describing marker ids remain transport-independent through
2053
+ // `decodeVideoMarkerId`; undecodable ids without matching metadata fall
2054
+ // through to the loader and degrade to plain text.
1990
2055
  // Hero videos on FETCHED entity types (webinars, releases, …) are
1991
2056
  // handled post-fetch inside ChatCardLoader (`itemVideoMetadata`) — the
1992
- // marker's ref plays no part in either path.
2057
+ // marker metadata is not required for that post-fetch path.
1993
2058
  if (chatRef.type === 'video') {
2059
+ const enrichedMetadata = validatedVideoMetadata(chatRef);
1994
2060
  const decoded = decodeVideoMarkerId(chatRef.id);
1995
- if (decoded) {
2061
+ const playableMetadata = enrichedMetadata ?? decoded;
2062
+ if (playableMetadata) {
1996
2063
  const displayRef: ChatRef = {
1997
2064
  ...chatRef,
1998
2065
  title: chatRef.title && chatRef.title !== chatRef.id ? chatRef.title : 'Video',
1999
2066
  url:
2000
2067
  chatRef.url ??
2001
- decoded.videoUrl ??
2002
- (decoded.youtubeUrl ? `https://www.youtube.com/watch?v=${decoded.youtubeUrl}` : null),
2003
- metadata: { ...(chatRef.metadata ?? {}), ...decoded },
2068
+ (typeof playableMetadata.videoUrl === 'string' ? playableMetadata.videoUrl : null) ??
2069
+ (typeof playableMetadata.youtubeUrl === 'string'
2070
+ ? `https://www.youtube.com/watch?v=${playableMetadata.youtubeUrl}`
2071
+ : null),
2072
+ metadata: playableMetadata,
2004
2073
  };
2005
2074
  return (
2006
2075
  <BlockCard inline={<ChatInlineVideoPill chatRef={displayRef} />}>
@@ -0,0 +1,64 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type { SlashCommandSummary } from '../../types/component.types';
3
+
4
+ const { embedAuthedFetchMock } = vi.hoisted(() => ({
5
+ embedAuthedFetchMock: vi.fn(),
6
+ }));
7
+
8
+ vi.mock('../../../../utils/embed-authed-fetch', () => ({
9
+ embedAuthedFetch: embedAuthedFetchMock,
10
+ }));
11
+
12
+ import { fetchSlashCommands } from '../use-slash-commands';
13
+
14
+ const command = (id: string): SlashCommandSummary => ({
15
+ id,
16
+ description: `${id} description`,
17
+ actions: [{ id: 'browse', label: 'Recent' }],
18
+ });
19
+
20
+ const commandsUrl = 'https://hub.example/api/docs/commands';
21
+
22
+ describe('fetchSlashCommands', () => {
23
+ beforeEach(() => {
24
+ embedAuthedFetchMock.mockReset();
25
+ });
26
+
27
+ it('fetches the full registry and filters normalized prefixes locally', async () => {
28
+ embedAuthedFetchMock.mockResolvedValue({
29
+ ok: true,
30
+ json: () => Promise.resolve({ commands: [command('roadmap'), command('webinars')] }),
31
+ });
32
+
33
+ const commands = await fetchSlashCommands(' W ', undefined, commandsUrl);
34
+
35
+ expect(commands.map(({ id }) => id)).toEqual(['webinars']);
36
+ expect(embedAuthedFetchMock).toHaveBeenCalledTimes(1);
37
+ const [requestedUrl] = embedAuthedFetchMock.mock.calls[0] as [string];
38
+ expect(new URL(requestedUrl).search).toBe('');
39
+ });
40
+
41
+ it('returns every command for an empty prefix', async () => {
42
+ const registry = [command('roadmap'), command('webinars')];
43
+ embedAuthedFetchMock.mockResolvedValue({
44
+ ok: true,
45
+ json: () => Promise.resolve({ commands: registry }),
46
+ });
47
+
48
+ await expect(fetchSlashCommands('', undefined, commandsUrl)).resolves.toEqual(registry);
49
+ });
50
+
51
+ it('caps non-empty prefix matches at ten commands', async () => {
52
+ embedAuthedFetchMock.mockResolvedValue({
53
+ ok: true,
54
+ json: () =>
55
+ Promise.resolve({
56
+ commands: Array.from({ length: 12 }, (_, index) => command(`webinar-${index}`)),
57
+ }),
58
+ });
59
+
60
+ const commands = await fetchSlashCommands('webinar', undefined, commandsUrl);
61
+
62
+ expect(commands).toHaveLength(10);
63
+ });
64
+ });
@@ -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
  }
@@ -24,10 +24,12 @@ import type { SlashCommandActionId, SlashCommandSummaryAction, SlashCommandSumma
24
24
  // canonical declaration.
25
25
  export type { SlashCommandActionId, SlashCommandSummaryAction, SlashCommandSummary };
26
26
 
27
+ const COMMANDS_MAX_RESULTS = 10;
28
+
27
29
  /**
28
- * Fetch slash-command suggestions for the CURRENT deployment platform,
29
- * matching id-prefix `prefix`. The chat source is server-resolved — the
30
- * client sends NO `source` param on the URL.
30
+ * Fetch the full slash-command registry for the CURRENT deployment platform,
31
+ * then match id-prefix `prefix` locally. The chat source is server-resolved —
32
+ * the client sends NO `source` param on the URL.
31
33
  *
32
34
  * `commandsUrl` is required — supplied by `useChatRuntime().endpoints.commandsUrl`
33
35
  * at the call site. NO default value.
@@ -56,13 +58,15 @@ export async function fetchSlashCommands(
56
58
  ): Promise<SlashCommandSummary[]> {
57
59
  try {
58
60
  const url = new URL(commandsUrl, window.location.origin);
59
- if (prefix) url.searchParams.set('q', prefix);
60
61
  // `headers: {}` opts out of the default `Content-Type: application/json`
61
62
  // — this is a bare GET with no body, so no content-type is needed.
62
63
  const res = await embedAuthedFetch(url.toString(), { signal, headers: {} });
63
64
  if (!res.ok) return [];
64
65
  const data = (await res.json()) as { commands?: SlashCommandSummary[] };
65
- return data.commands ?? [];
66
+ const commands = data.commands ?? [];
67
+ const normalizedPrefix = prefix.trim().toLowerCase();
68
+ if (!normalizedPrefix) return commands;
69
+ return commands.filter(({ id }) => id.startsWith(normalizedPrefix)).slice(0, COMMANDS_MAX_RESULTS);
66
70
  } catch (err) {
67
71
  // Cancellation (unmount / dep change) MUST propagate so react-query treats
68
72
  // it as cancelled, not as a successful empty result. Every OTHER failure
@@ -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';