@flamingo-stack/openframe-frontend-core 0.0.577 → 0.0.578-snapshot.20260831161155

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 (228) hide show
  1. package/dist/{chunk-XBSW7IF6.cjs → chunk-36TJYCF3.cjs} +27 -27
  2. package/dist/{chunk-XBSW7IF6.cjs.map → chunk-36TJYCF3.cjs.map} +1 -1
  3. package/dist/{chunk-ET3Q4FIZ.cjs → chunk-37XJYOAY.cjs} +17 -17
  4. package/dist/{chunk-ET3Q4FIZ.cjs.map → chunk-37XJYOAY.cjs.map} +1 -1
  5. package/dist/{chunk-4D2BR756.js → chunk-3AX3ZEY3.js} +53 -32
  6. package/dist/chunk-3AX3ZEY3.js.map +1 -0
  7. package/dist/{chunk-JQI726T3.js → chunk-3LMYQGAJ.js} +3 -2
  8. package/dist/chunk-3LMYQGAJ.js.map +1 -0
  9. package/dist/{chunk-XBBCUC32.cjs → chunk-4FJSN2XW.cjs} +15 -15
  10. package/dist/{chunk-XBBCUC32.cjs.map → chunk-4FJSN2XW.cjs.map} +1 -1
  11. package/dist/{chunk-RCLJSENK.cjs → chunk-4HGRXFQP.cjs} +6 -6
  12. package/dist/{chunk-RCLJSENK.cjs.map → chunk-4HGRXFQP.cjs.map} +1 -1
  13. package/dist/{chunk-NQCGDZJ3.cjs → chunk-4LZ7B5H5.cjs} +5 -5
  14. package/dist/{chunk-NQCGDZJ3.cjs.map → chunk-4LZ7B5H5.cjs.map} +1 -1
  15. package/dist/{chunk-IQFXDS5O.js → chunk-4WSKSNXL.js} +10 -10
  16. package/dist/{chunk-NEKT67O5.js → chunk-5Y6TPFW7.js} +6 -6
  17. package/dist/{chunk-XG2WNF4L.cjs → chunk-A6C5HOD7.cjs} +27 -27
  18. package/dist/{chunk-XG2WNF4L.cjs.map → chunk-A6C5HOD7.cjs.map} +1 -1
  19. package/dist/{chunk-AQ5MZVYN.js → chunk-AKPLEWMH.js} +3 -3
  20. package/dist/{chunk-HSOMYCUG.cjs → chunk-BCJQ7CJE.cjs} +13 -13
  21. package/dist/{chunk-HSOMYCUG.cjs.map → chunk-BCJQ7CJE.cjs.map} +1 -1
  22. package/dist/{chunk-RUJKOU5F.js → chunk-BW4WDUSI.js} +3 -3
  23. package/dist/{chunk-NP3XN6ZX.js → chunk-CP5EYMNL.js} +278 -162
  24. package/dist/chunk-CP5EYMNL.js.map +1 -0
  25. package/dist/{chunk-FCVAVOXS.js → chunk-D44S2QLI.js} +3 -3
  26. package/dist/{chunk-U3SDSA5K.js → chunk-DB3TV4CW.js} +7 -7
  27. package/dist/{chunk-KE2TCUOH.cjs → chunk-FEVLXRTC.cjs} +537 -421
  28. package/dist/chunk-FEVLXRTC.cjs.map +1 -0
  29. package/dist/{chunk-ZBJ6XSDO.js → chunk-H4BPHGU5.js} +4 -4
  30. package/dist/{chunk-6FJABKPX.cjs → chunk-H54EPP3P.cjs} +9 -9
  31. package/dist/{chunk-6FJABKPX.cjs.map → chunk-H54EPP3P.cjs.map} +1 -1
  32. package/dist/{chunk-4KR7JWKB.js → chunk-HGA74X35.js} +5 -5
  33. package/dist/{chunk-5DPCIIJ7.cjs → chunk-HHJVIHVR.cjs} +92 -92
  34. package/dist/{chunk-5DPCIIJ7.cjs.map → chunk-HHJVIHVR.cjs.map} +1 -1
  35. package/dist/{chunk-WOHHZZJK.cjs → chunk-IFYQ7DHV.cjs} +62 -62
  36. package/dist/{chunk-WOHHZZJK.cjs.map → chunk-IFYQ7DHV.cjs.map} +1 -1
  37. package/dist/{chunk-QYQXAXVR.cjs → chunk-IKDC3R7V.cjs} +4 -3
  38. package/dist/chunk-IKDC3R7V.cjs.map +1 -0
  39. package/dist/{chunk-W6JA6SFD.js → chunk-JRYS4VMV.js} +45 -45
  40. package/dist/chunk-JRYS4VMV.js.map +1 -0
  41. package/dist/{chunk-2YCUEUIZ.js → chunk-L2CF2QZW.js} +8 -8
  42. package/dist/{chunk-2BFZHTSD.cjs → chunk-LEISUAHF.cjs} +18 -8
  43. package/dist/chunk-LEISUAHF.cjs.map +1 -0
  44. package/dist/{chunk-GDWBJFMG.cjs → chunk-M5GWCMYE.cjs} +42 -42
  45. package/dist/{chunk-GDWBJFMG.cjs.map → chunk-M5GWCMYE.cjs.map} +1 -1
  46. package/dist/{chunk-JCH6HGQD.js → chunk-MCWAZ5XI.js} +2 -2
  47. package/dist/{chunk-PQETCTJ4.js → chunk-MXOSTJAG.js} +4 -3
  48. package/dist/chunk-MXOSTJAG.js.map +1 -0
  49. package/dist/{chunk-MIY5FYX3.cjs → chunk-NNFQLTJ3.cjs} +4 -3
  50. package/dist/chunk-NNFQLTJ3.cjs.map +1 -0
  51. package/dist/{chunk-CFTZAZ7R.cjs → chunk-NPVDKOGD.cjs} +28 -28
  52. package/dist/{chunk-CFTZAZ7R.cjs.map → chunk-NPVDKOGD.cjs.map} +1 -1
  53. package/dist/{chunk-LYOGSVFO.js → chunk-O2FTVZOD.js} +5 -5
  54. package/dist/{chunk-CIUYHBAY.cjs → chunk-OGZAFM6M.cjs} +5 -5
  55. package/dist/{chunk-CIUYHBAY.cjs.map → chunk-OGZAFM6M.cjs.map} +1 -1
  56. package/dist/{chunk-THDGDYJ3.js → chunk-PMENIZXI.js} +7 -7
  57. package/dist/{chunk-TU32TSAK.js → chunk-PWOYEUIQ.js} +7 -7
  58. package/dist/{chunk-RSMUAAPW.js → chunk-QCP2KV7P.js} +5 -5
  59. package/dist/{chunk-RNUH5IHZ.js → chunk-QGJYWLF4.js} +3 -3
  60. package/dist/{chunk-75X6BRID.cjs → chunk-RKGOVTXZ.cjs} +500 -479
  61. package/dist/chunk-RKGOVTXZ.cjs.map +1 -0
  62. package/dist/{chunk-HM2R5MDR.js → chunk-T5HDPHN6.js} +13 -3
  63. package/dist/chunk-T5HDPHN6.js.map +1 -0
  64. package/dist/{chunk-GPWU3PPR.cjs → chunk-VEHZNQ77.cjs} +30 -30
  65. package/dist/{chunk-GPWU3PPR.cjs.map → chunk-VEHZNQ77.cjs.map} +1 -1
  66. package/dist/{chunk-APFGR2KK.cjs → chunk-YW4WBNYV.cjs} +41 -41
  67. package/dist/{chunk-APFGR2KK.cjs.map → chunk-YW4WBNYV.cjs.map} +1 -1
  68. package/dist/{chunk-36NFJULV.cjs → chunk-ZI27ALYX.cjs} +41 -41
  69. package/dist/chunk-ZI27ALYX.cjs.map +1 -0
  70. package/dist/components/case-studies/index.cjs +13 -13
  71. package/dist/components/case-studies/index.js +7 -7
  72. package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
  73. package/dist/components/chat/entity-cards/dispatch.d.ts.map +1 -1
  74. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  75. package/dist/components/chat/hooks/use-unified-chat.d.ts.map +1 -1
  76. package/dist/components/chat/index.cjs +4 -4
  77. package/dist/components/chat/index.js +3 -3
  78. package/dist/components/chat/nav-link-anchor-via-runtime.d.ts.map +1 -1
  79. package/dist/components/chat/types/unified-chat-state.types.d.ts +9 -2
  80. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
  81. package/dist/components/chat/utils/chat-nav-resolution.d.ts +9 -3
  82. package/dist/components/chat/utils/chat-nav-resolution.d.ts.map +1 -1
  83. package/dist/components/chat/utils/execute-navigation.d.ts +11 -0
  84. package/dist/components/chat/utils/execute-navigation.d.ts.map +1 -1
  85. package/dist/components/chat/utils/index.d.ts +1 -1
  86. package/dist/components/chat/utils/index.d.ts.map +1 -1
  87. package/dist/components/chat/utils/nav-anchor-props.d.ts +4 -0
  88. package/dist/components/chat/utils/nav-anchor-props.d.ts.map +1 -1
  89. package/dist/components/chat/utils/resolve-fetched-card-href.d.ts +42 -3
  90. package/dist/components/chat/utils/resolve-fetched-card-href.d.ts.map +1 -1
  91. package/dist/components/contact/index.cjs +8 -8
  92. package/dist/components/contact/index.js +7 -7
  93. package/dist/components/docs/index.cjs +11 -11
  94. package/dist/components/docs/index.js +10 -10
  95. package/dist/components/embeds/index.cjs +8 -8
  96. package/dist/components/embeds/index.js +7 -7
  97. package/dist/components/faq/index.cjs +8 -8
  98. package/dist/components/faq/index.js +7 -7
  99. package/dist/components/features/board/board.d.ts.map +1 -1
  100. package/dist/components/features/index.cjs +9 -7
  101. package/dist/components/features/index.cjs.map +1 -1
  102. package/dist/components/features/index.js +8 -6
  103. package/dist/components/features/sortable-list/index.d.ts +2 -1
  104. package/dist/components/features/sortable-list/index.d.ts.map +1 -1
  105. package/dist/components/features/sortable-list/sortable-list.d.ts +8 -1
  106. package/dist/components/features/sortable-list/sortable-list.d.ts.map +1 -1
  107. package/dist/components/features/sortable-list/sortable-move-buttons.d.ts +24 -0
  108. package/dist/components/features/sortable-list/sortable-move-buttons.d.ts.map +1 -0
  109. package/dist/components/features/sortable-list/use-sortable-item.d.ts +12 -4
  110. package/dist/components/features/sortable-list/use-sortable-item.d.ts.map +1 -1
  111. package/dist/components/features/ticket-status-config-list/ticket-status-config-list.d.ts +21 -5
  112. package/dist/components/features/ticket-status-config-list/ticket-status-config-list.d.ts.map +1 -1
  113. package/dist/components/help-center-pages/index.cjs +27 -27
  114. package/dist/components/help-center-pages/index.js +18 -18
  115. package/dist/components/index.cjs +146 -144
  116. package/dist/components/index.cjs.map +1 -1
  117. package/dist/components/index.js +20 -18
  118. package/dist/components/index.js.map +1 -1
  119. package/dist/components/meeting-scheduler/index.cjs +47 -47
  120. package/dist/components/meeting-scheduler/index.js +6 -6
  121. package/dist/components/navigation/index.cjs +10 -10
  122. package/dist/components/navigation/index.js +9 -9
  123. package/dist/components/onboarding-guides/index.cjs +10 -10
  124. package/dist/components/onboarding-guides/index.js +9 -9
  125. package/dist/components/related-content/index.cjs +3 -3
  126. package/dist/components/related-content/index.js +2 -2
  127. package/dist/components/tickets/index.cjs +11 -11
  128. package/dist/components/tickets/index.js +10 -10
  129. package/dist/components/ui/file-manager/index.cjs +4 -4
  130. package/dist/components/ui/file-manager/index.js +1 -1
  131. package/dist/components/ui/index.cjs +7 -7
  132. package/dist/components/ui/index.js +6 -6
  133. package/dist/components/ui/input.d.ts +2 -0
  134. package/dist/components/ui/input.d.ts.map +1 -1
  135. package/dist/components/ui/textarea.d.ts +2 -0
  136. package/dist/components/ui/textarea.d.ts.map +1 -1
  137. package/dist/components/ui/ticket-status-config-row.d.ts +10 -4
  138. package/dist/components/ui/ticket-status-config-row.d.ts.map +1 -1
  139. package/dist/hooks/index.cjs +7 -3
  140. package/dist/hooks/index.cjs.map +1 -1
  141. package/dist/hooks/index.js +6 -2
  142. package/dist/hooks/ui/index.d.ts +1 -0
  143. package/dist/hooks/ui/index.d.ts.map +1 -1
  144. package/dist/hooks/ui/use-drag-and-drop-enabled.d.ts +31 -0
  145. package/dist/hooks/ui/use-drag-and-drop-enabled.d.ts.map +1 -0
  146. package/dist/hooks/ui/use-media-query.d.ts.map +1 -1
  147. package/dist/index.cjs +18 -12
  148. package/dist/index.cjs.map +1 -1
  149. package/dist/index.js +16 -10
  150. package/dist/index.js.map +1 -1
  151. package/dist/schemas/meeting-booking-schema.d.ts +2 -2
  152. package/dist/utils/content-href.d.ts +26 -5
  153. package/dist/utils/content-href.d.ts.map +1 -1
  154. package/dist/utils/index.cjs +1 -1
  155. package/dist/utils/index.cjs.map +1 -1
  156. package/dist/utils/index.d.ts +1 -1
  157. package/dist/utils/index.d.ts.map +1 -1
  158. package/dist/utils/index.js +1 -1
  159. package/dist/utils/index.js.map +1 -1
  160. package/package.json +1 -4
  161. package/src/components/chat/embeddable-chat.tsx +9 -2
  162. package/src/components/chat/entity-cards/.dispatch.md +1 -1
  163. package/src/components/chat/entity-cards/dispatch.tsx +43 -23
  164. package/src/components/chat/hooks/.use-unified-chat.md +5 -1
  165. package/src/components/chat/hooks/use-nats-chat-adapter.ts +4 -7
  166. package/src/components/chat/hooks/use-unified-chat.ts +8 -2
  167. package/src/components/chat/nav-link-anchor-via-runtime.tsx +8 -10
  168. package/src/components/chat/types/.unified-chat-state.types.md +2 -1
  169. package/src/components/chat/types/unified-chat-state.types.ts +9 -2
  170. package/src/components/chat/utils/__tests__/execute-navigation.test.ts +190 -0
  171. package/src/components/chat/utils/__tests__/resolve-fetched-card-href.test.ts +75 -2
  172. package/src/components/chat/utils/chat-nav-resolution.ts +9 -3
  173. package/src/components/chat/utils/execute-navigation.ts +32 -2
  174. package/src/components/chat/utils/index.ts +1 -1
  175. package/src/components/chat/utils/nav-anchor-props.ts +4 -2
  176. package/src/components/chat/utils/resolve-fetched-card-href.ts +50 -2
  177. package/src/components/features/board/.board.md +1 -0
  178. package/src/components/features/board/board.tsx +11 -1
  179. package/src/components/features/sortable-list/.sortable-list.md +23 -12
  180. package/src/components/features/sortable-list/index.ts +2 -1
  181. package/src/components/features/sortable-list/sortable-list.tsx +16 -5
  182. package/src/components/features/sortable-list/sortable-move-buttons.tsx +81 -0
  183. package/src/components/features/sortable-list/use-sortable-item.ts +25 -13
  184. package/src/components/features/ticket-status-config-list/ticket-status-config-list.tsx +55 -62
  185. package/src/components/ui/.ticket-status-config-row.md +11 -9
  186. package/src/components/ui/input.tsx +5 -2
  187. package/src/components/ui/textarea.tsx +5 -2
  188. package/src/components/ui/ticket-status-config-row.tsx +162 -70
  189. package/src/hooks/ui/.index.md +1 -0
  190. package/src/hooks/ui/.use-drag-and-drop-enabled.md +32 -0
  191. package/src/hooks/ui/index.ts +1 -0
  192. package/src/hooks/ui/use-drag-and-drop-enabled.ts +37 -0
  193. package/src/hooks/ui/use-media-query.ts +3 -2
  194. package/src/utils/.content-href.md +1 -1
  195. package/src/utils/__tests__/content-href.test.ts +3 -0
  196. package/src/utils/content-href.ts +30 -3
  197. package/src/utils/index.ts +1 -0
  198. package/dist/chunk-2BFZHTSD.cjs.map +0 -1
  199. package/dist/chunk-36NFJULV.cjs.map +0 -1
  200. package/dist/chunk-4D2BR756.js.map +0 -1
  201. package/dist/chunk-75X6BRID.cjs.map +0 -1
  202. package/dist/chunk-HM2R5MDR.js.map +0 -1
  203. package/dist/chunk-JQI726T3.js.map +0 -1
  204. package/dist/chunk-KE2TCUOH.cjs.map +0 -1
  205. package/dist/chunk-MIY5FYX3.cjs.map +0 -1
  206. package/dist/chunk-NP3XN6ZX.js.map +0 -1
  207. package/dist/chunk-PQETCTJ4.js.map +0 -1
  208. package/dist/chunk-QYQXAXVR.cjs.map +0 -1
  209. package/dist/chunk-W6JA6SFD.js.map +0 -1
  210. package/dist/components/chat/utils/nav-click-handler.d.ts +0 -24
  211. package/dist/components/chat/utils/nav-click-handler.d.ts.map +0 -1
  212. package/src/components/chat/utils/.nav-click-handler.md +0 -43
  213. package/src/components/chat/utils/nav-click-handler.ts +0 -34
  214. /package/dist/{chunk-IQFXDS5O.js.map → chunk-4WSKSNXL.js.map} +0 -0
  215. /package/dist/{chunk-NEKT67O5.js.map → chunk-5Y6TPFW7.js.map} +0 -0
  216. /package/dist/{chunk-AQ5MZVYN.js.map → chunk-AKPLEWMH.js.map} +0 -0
  217. /package/dist/{chunk-RUJKOU5F.js.map → chunk-BW4WDUSI.js.map} +0 -0
  218. /package/dist/{chunk-FCVAVOXS.js.map → chunk-D44S2QLI.js.map} +0 -0
  219. /package/dist/{chunk-U3SDSA5K.js.map → chunk-DB3TV4CW.js.map} +0 -0
  220. /package/dist/{chunk-ZBJ6XSDO.js.map → chunk-H4BPHGU5.js.map} +0 -0
  221. /package/dist/{chunk-4KR7JWKB.js.map → chunk-HGA74X35.js.map} +0 -0
  222. /package/dist/{chunk-2YCUEUIZ.js.map → chunk-L2CF2QZW.js.map} +0 -0
  223. /package/dist/{chunk-JCH6HGQD.js.map → chunk-MCWAZ5XI.js.map} +0 -0
  224. /package/dist/{chunk-LYOGSVFO.js.map → chunk-O2FTVZOD.js.map} +0 -0
  225. /package/dist/{chunk-THDGDYJ3.js.map → chunk-PMENIZXI.js.map} +0 -0
  226. /package/dist/{chunk-TU32TSAK.js.map → chunk-PWOYEUIQ.js.map} +0 -0
  227. /package/dist/{chunk-RSMUAAPW.js.map → chunk-QCP2KV7P.js.map} +0 -0
  228. /package/dist/{chunk-RNUH5IHZ.js.map → chunk-QGJYWLF4.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.577",
3
+ "version": "0.0.578-snapshot.20260831161155",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -350,9 +350,6 @@
350
350
  "@atlaskit/pragmatic-drag-and-drop": "^3.0.0",
351
351
  "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^3.0.1",
352
352
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "^2.1.0",
353
- "@dnd-kit/core": "^6.3.1",
354
- "@dnd-kit/sortable": "^10.0.0",
355
- "@dnd-kit/utilities": "^3.2.2",
356
353
  "@eslint/js": "^9.39.0",
357
354
  "@floating-ui/react": "^0.27.15",
358
355
  "@headlessui/react": "^2.2.9",
@@ -98,9 +98,9 @@ import type { UnifiedChatState } from './types/unified-chat-state.types';
98
98
  import { formatChatAttachmentMarkdownForBubble } from './utils/chat-attachment-markdown';
99
99
  import { resolveHrefForRuntime } from './utils/chat-nav-resolution';
100
100
  import { chatChipClass } from './utils/chip-styles';
101
+ import { executeNavigation } from './utils/execute-navigation';
101
102
  import { resolveIcon } from './utils/icon-library';
102
103
  import { computeIsNewTab, newTabAnchorAttrs } from './utils/nav-anchor-props';
103
- import { handleChatNavClick } from './utils/nav-click-handler';
104
104
  import { formatSingularLookupInvocation } from './utils/slash-dispatch-utils';
105
105
  import { getSourceIconName } from './utils/source-icons';
106
106
  import { resolveSourceRowCTA, sourceRowCtxFromRuntime } from './utils/source-row-cta';
@@ -732,7 +732,14 @@ function SourceChip({
732
732
  const buildClickHandler =
733
733
  (href: string, path: string | null | undefined, targetPlatform: string | null, isNewTab: boolean) =>
734
734
  (e: React.MouseEvent<HTMLAnchorElement>) => {
735
- const handled = handleChatNavClick(e, runtime, { href, path, targetPlatform }, router.push);
735
+ const handled = executeNavigation({
736
+ event: e,
737
+ runtime,
738
+ href,
739
+ path,
740
+ targetPlatform,
741
+ fallbackNavigate: router.push,
742
+ });
736
743
  if (handled && !isNewTab && onClose) onClose();
737
744
  };
738
745
 
@@ -52,5 +52,5 @@ const card = renderChatInlineEntityCard(chatRef, {
52
52
  ## Related Files
53
53
 
54
54
  - [`chat-ref.types.ts`](https://github.com/flamingo-stack/openframe-oss-lib/blob/main/src/types/chat-ref.types.ts) — `ChatRef` shape
55
- - [`nav-click-handler.ts`](https://github.com/flamingo-stack/openframe-oss-lib/blob/main/src/utils/nav-click-handler.ts) — `handleChatNavClick`
55
+ - [`execute-navigation.ts`](https://github.com/flamingo-stack/openframe-oss-lib/blob/main/src/utils/execute-navigation.ts) — `executeNavigation`
56
56
  - [`product-release-card-defaults.ts`](https://github.com/flamingo-stack/openframe-oss-lib/blob/main/src/components/cards/product-release-card-defaults.ts) — Fallback prop builder
@@ -83,10 +83,10 @@ import type { BaseProgramItem, ProgramConfig } from '../types/entities/program-t
83
83
  import { getStatusColorScheme } from '../utils/agent-status-message';
84
84
  import { resolveHrefForRuntime } from '../utils/chat-nav-resolution';
85
85
  import { safeHref } from '../utils/compact-card-classes';
86
+ import { executeNavigation } from '../utils/execute-navigation';
86
87
  import { clickupTaskUrl } from '../utils/external-app-urls';
87
88
  import { computeIsNewTab, buildAnchorProps } from '../utils/nav-anchor-props';
88
- import { handleChatNavClick } from '../utils/nav-click-handler';
89
- import { resolveFetchedCardHref, readFetchedCardTitle } from '../utils/resolve-fetched-card-href';
89
+ import { resolveFetchedCardHref, pickFetchedCardHref, readFetchedCardTitle } from '../utils/resolve-fetched-card-href';
90
90
  import { getSourceLabel } from '../utils/source-icons';
91
91
  import { resolveSourceRowCTA, resolveSourceIcon, sourceRowCtxFromRuntime } from '../utils/source-row-cta';
92
92
  import { BlockCard } from './block-card';
@@ -1215,13 +1215,18 @@ interface ChatCardRegistryEntry {
1215
1215
  * non-null `href`, the pre-computed `isNewTab` reflects the actual
1216
1216
  * destination, and the click goes through `handleChatNavClick` (no
1217
1217
  * silent bypass of embed-mode / close-on-nav). Takes precedence over
1218
- * the `composeContentUrl` fallback below. */
1218
+ * the `composeContentUrl` fallback below — but NOT over an explicit
1219
+ * host `override` for the type (see `pickFetchedCardHref`). */
1219
1220
  fallbackHref?: (item: ChatCardItem) => string | null;
1220
- /** Opt OUT of the post-fetch `composeContentUrl` fallback (see
1221
+ /** Opt OUT of the post-fetch `composeContentUrl` SYNTHESIS (see
1221
1222
  * `resolveFetchedCardHref`). Set for types with NO public destination
1222
1223
  * — the seam would otherwise synthesize `<contentOrigin>/<type>/<id>`
1223
- * from its default branch and hand the user a 404. Types with a real
1224
- * route (hosted, or covered by a host `override`) leave it unset. */
1224
+ * from its default branch and hand the user a 404.
1225
+ *
1226
+ * It does NOT opt out of the seam itself: an explicit host `override`
1227
+ * for the type still wins (`pickFetchedCardHref`). That split is the
1228
+ * point — a host that re-homes a type must be able to say so even when
1229
+ * the row already carries the content host's own url. */
1225
1230
  noComposedHref?: boolean;
1226
1231
  }
1227
1232
 
@@ -1234,6 +1239,12 @@ interface ChatCardRegistryEntry {
1234
1239
  * (`fallbackHref`), no `<origin>/<type>/<id>` synthesis
1235
1240
  * (`noComposedHref`). Loading shows the skeleton, exactly like every
1236
1241
  * other fetch card.
1242
+ *
1243
+ * NOTE `item.url` is minted by the CONTENT host for its OWN surface, so an
1244
+ * embedder that serves the same rows elsewhere overrides the type in its
1245
+ * `composeContentUrl` config and that wins here — see `pickFetchedCardHref`.
1246
+ * (Observed: hub tickets are `/tickets?ticket=…`, which in the OpenFrame
1247
+ * console resolved against its own unrelated `/tickets` board.)
1237
1248
  */
1238
1249
  function refHydratedEntry(
1239
1250
  docType: string,
@@ -1667,7 +1678,7 @@ function ChatCardNavWrap({
1667
1678
  if (targetEl?.closest?.('button')) return;
1668
1679
  if (!targetEl?.closest?.('a')) return;
1669
1680
 
1670
- const handled = handleChatNavClick(e, runtime, { href, path, targetPlatform }, router.push);
1681
+ const handled = executeNavigation({ event: e, runtime, href, path, targetPlatform, fallbackNavigate: router.push });
1671
1682
  if (!handled) return;
1672
1683
  // Modifier-clicks fall through (handled=false) without stopPropagation
1673
1684
  // so ancestor telemetry handlers still see the bubble.
@@ -1763,16 +1774,22 @@ export function ChatCardLoader({
1763
1774
  // visit. `safeHref` blocks `javascript:` / `data:` payloads even
1764
1775
  // though the registry callers compose hub-internal strings today.
1765
1776
  //
1766
- // Two sources, in order:
1777
+ // Two producers, ranked by `pickFetchedCardHref` (which owns the
1778
+ // precedence and its rationale):
1767
1779
  // 1. the registry's per-type `fallbackHref` — an explicit non-content
1768
- // destination (marketing campaign → `/admin/...`);
1780
+ // destination (marketing campaign → `/admin/...`, or a ref-hydrated
1781
+ // row's own `item.url`);
1769
1782
  // 2. the host's `composeContentUrl` seam via `resolveFetchedCardHref` —
1770
1783
  // the SAME resolver page cards go through. This is what makes cards
1771
1784
  // clickable on every transport: the wire ships bare
1772
1785
  // `[card://type:id]` markers with no metadata, so the ref reaches
1773
1786
  // us with `url: null` and `resolveSourceRowCTA` has nothing to route.
1787
+ // …except an EXPLICIT host override outranks both: the seam is asked
1788
+ // even for `fallbackHref` / `noComposedHref` types now, and its answer is
1789
+ // taken only when the host actually re-homed the type (never when the
1790
+ // composer merely synthesized one) — the case those two flags guard against.
1774
1791
  const composedHref =
1775
- fetchEntry?.contentRefType && !resolvedChatRef.url && item && !fetchEntry.fallbackHref && !fetchEntry.noComposedHref
1792
+ fetchEntry?.contentRefType && !resolvedChatRef.url && item
1776
1793
  ? resolveFetchedCardHref({
1777
1794
  contentRefType: fetchEntry.contentRefType,
1778
1795
  id: resolvedChatRef.id,
@@ -1780,19 +1797,22 @@ export function ChatCardLoader({
1780
1797
  composeContentUrl: runtime.composeContentUrl,
1781
1798
  })
1782
1799
  : null;
1783
- const hrefResolvedChatRef: ChatRef =
1784
- fetchEntry && !resolvedChatRef.url && item && fetchEntry.fallbackHref
1785
- ? {
1786
- ...resolvedChatRef,
1787
- url: safeHref(fetchEntry.fallbackHref(item)),
1788
- }
1789
- : composedHref
1790
- ? {
1791
- ...resolvedChatRef,
1792
- url: safeHref(composedHref.href),
1793
- targetPlatform: composedHref.targetPlatform ?? resolvedChatRef.targetPlatform ?? null,
1794
- }
1795
- : resolvedChatRef;
1800
+ const hrefChoice =
1801
+ fetchEntry && !resolvedChatRef.url && item
1802
+ ? pickFetchedCardHref({
1803
+ composed: composedHref,
1804
+ itemHref: fetchEntry.fallbackHref?.(item) ?? null,
1805
+ allowComposed: !fetchEntry.noComposedHref,
1806
+ })
1807
+ : null;
1808
+ const hrefResolvedChatRef: ChatRef = hrefChoice
1809
+ ? {
1810
+ ...resolvedChatRef,
1811
+ url: safeHref(hrefChoice.href),
1812
+ // The `item` branch carries no platform of its own — keep the ref's.
1813
+ targetPlatform: hrefChoice.targetPlatform ?? resolvedChatRef.targetPlatform ?? null,
1814
+ }
1815
+ : resolvedChatRef;
1796
1816
 
1797
1817
  // Title enrichment, same synthetic-ref gap as the href above: a Mingo
1798
1818
  // `[card://type:id]` marker produces `title: <id>` because the transport
@@ -26,7 +26,11 @@ Hook input shape.
26
26
  ### `useUnifiedChat(options)`
27
27
  Main hook. Returns a stable `UnifiedChatState` composed of:
28
28
  - **Active adapter state** — messages, streaming phase, token usage, connection
29
- - **Stable action callbacks** — `sendMessage`, `stopMessage`, `clearMessages`, `discussRef`, `displayRef`
29
+ - **Stable action callbacks** — `sendMessage`, `stopMessage`, `clearMessages`, `discussRef`, and
30
+ `displayRef` **only when the active adapter has one**. `displayRef` is optional on the state and
31
+ the NATS/Mingo adapters omit it, because consumers gate the card menu's "Display" row on the
32
+ callback being there; forwarding an always-truthy wrapper around a missing method would advertise
33
+ a display path that isn't there and throw on click.
30
34
  - **Dialog management** — `selectDialog`, `startNewDialog`, `deleteDialog`, `renameDialog`, `archiveDialog`, `loadMoreDialogs`, `setDialogScope`, `reloadDialogs`, `loadMoreMessages`
31
35
  - **Approvals** — `approveRequest`, `rejectRequest`
32
36
 
@@ -1093,11 +1093,10 @@ export function useNatsChatAdapter(
1093
1093
  },
1094
1094
  [sendMessage],
1095
1095
  );
1096
- // Display stays a no-op: it dispatches a `/… display "<x>"` slash command,
1097
- // and the agent transport has no slash-command registry.
1098
- const displayRef = useCallback((_ref: ChatRef) => {
1099
- /* no-op in Mingo mode */
1100
- }, []);
1096
+ // No `displayRef` here: it dispatches a `/… display "<x>"` slash command and
1097
+ // the agent transport has no slash-command registry. OMITTED rather than
1098
+ // stubbed — the card menu gates on the callback's presence, so a stub costs
1099
+ // the row "Ask Mingo", which does work in Mingo mode.
1101
1100
 
1102
1101
  // ─── Return shape ─────────────────────────────────────────────────────────
1103
1102
 
@@ -1115,7 +1114,6 @@ export function useNatsChatAdapter(
1115
1114
  stopMessage,
1116
1115
  clearMessages,
1117
1116
  discussRef,
1118
- displayRef,
1119
1117
  // Model badge + token usage for the composer's `<ModelDisplay>`.
1120
1118
  // Model: live `metadata` frame → config baseline (host AI-config).
1121
1119
  // Tokens: live `token_usage` frame / dialog snapshot (`dialogTokenUsage`).
@@ -1160,7 +1158,6 @@ export function useNatsChatAdapter(
1160
1158
  stopMessage,
1161
1159
  clearMessages,
1162
1160
  discussRef,
1163
- displayRef,
1164
1161
  dialogs,
1165
1162
  dialogId,
1166
1163
  selectDialog,
@@ -153,10 +153,16 @@ export function useUnifiedChat(options: UseUnifiedChatOptions): UnifiedChatState
153
153
  (ref: Parameters<UnifiedChatState['discussRef']>[0]) => activeStateRef.current.discussRef(ref),
154
154
  [],
155
155
  );
156
- const displayRef = useCallback(
157
- (ref: Parameters<UnifiedChatState['displayRef']>[0]) => activeStateRef.current.displayRef(ref),
156
+ // `displayRef` is OPTIONAL on the state and consumers gate the "Display"
157
+ // affordance on its presence, so the forward has to be able to be absent
158
+ // too: an always-truthy wrapper around a missing method advertises a
159
+ // display path that isn't there and throws when the menu row is clicked.
160
+ // Only the flag flips identity — the wrapper itself stays stable.
161
+ const displayRefForward = useCallback(
162
+ (ref: Parameters<NonNullable<UnifiedChatState['displayRef']>>[0]) => activeStateRef.current.displayRef?.(ref),
158
163
  [],
159
164
  );
165
+ const displayRef = activeState.displayRef ? displayRefForward : undefined;
160
166
 
161
167
  // Dialog-management forwards — one thin wrapper per action so the
162
168
  // returned identity stays stable as long as the active adapter's
@@ -27,8 +27,8 @@ import { useRequiredChatRuntime } from '../../contexts/chat-runtime-context';
27
27
  import { useRouter } from '../../embed-shims/next-navigation';
28
28
  import { useChatPanel } from './chat-panel-context';
29
29
  import { resolveHrefForRuntime } from './utils/chat-nav-resolution';
30
+ import { executeNavigation } from './utils/execute-navigation';
30
31
  import { computeIsNewTab, newTabAnchorAttrs } from './utils/nav-anchor-props';
31
- import { handleChatNavClick } from './utils/nav-click-handler';
32
32
 
33
33
  export interface NavLinkAnchorViaRuntimeProps {
34
34
  href: string;
@@ -54,16 +54,14 @@ export function NavLinkAnchorViaRuntime({
54
54
  const isNewTab = computeIsNewTab(runtime, resolvedHref, targetPlatform ?? null);
55
55
 
56
56
  const onClick = (e: MouseEvent<HTMLAnchorElement>) => {
57
- const handled = handleChatNavClick(
58
- e,
57
+ const handled = executeNavigation({
58
+ event: e,
59
59
  runtime,
60
- {
61
- href: resolvedHref,
62
- path,
63
- targetPlatform,
64
- },
65
- router.push,
66
- );
60
+ href: resolvedHref,
61
+ path,
62
+ targetPlatform,
63
+ fallbackNavigate: router.push,
64
+ });
67
65
  if (handled && !isNewTab && panel?.closeChat) panel.closeChat();
68
66
  };
69
67
  return (
@@ -51,6 +51,7 @@ function MyChatShell({ chat }: { chat: UnifiedChatState }) {
51
51
  | `currentProvider` / `currentModelLabel` | ✅ Populated | `null` |
52
52
  | `currentUsageBreakdown` | ✅ After trailing frame | `null` |
53
53
  | `DialogTokenUsage` (separate) | `null` | ✅ From GraphQL |
54
- | `discussRef` / `displayRef` | ✅ Active | no-op |
54
+ | `discussRef` | ✅ Active | ✅ Active (prompt carries the reference as text) |
55
+ | `displayRef` | ✅ Active | **omitted** (optional — consumers fall back to "Ask Mingo") |
55
56
 
56
57
  > **Source:** [`unified-chat-state.types.ts`](https://github.com/flamingo-stack/openframe-oss-lib/blob/main/unified-chat-state.types.ts)
@@ -286,9 +286,16 @@ export interface UnifiedChatState {
286
286
 
287
287
  /**
288
288
  * Trigger the chat's "Display this row" affordance — same as `discussRef`
289
- * but for read-only card display rather than a question. Guide-mode only.
289
+ * but for read-only card display rather than a question. Guide-mode only:
290
+ * it dispatches a `/<cmd> display "<x>"` slash command, and only the SSE
291
+ * transport has a slash-command registry.
292
+ *
293
+ * OPTIONAL, and OMITTED — not stubbed — by adapters that have no display
294
+ * path. Consumers gate the affordance on its presence (`entity-cards`'
295
+ * dispatcher falls back to "Ask Mingo" when it is absent), so a no-op stub
296
+ * renders a dead "Display" row where the action that works belongs.
290
297
  */
291
- displayRef: (ref: ChatRef) => void;
298
+ displayRef?: (ref: ChatRef) => void;
292
299
 
293
300
  // ─── Per-turn LLM metadata (Guide/SSE only) ───────────────────────────────
294
301
  /**
@@ -0,0 +1,190 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type { ChatRuntime } from '../../../../contexts/chat-runtime-context';
3
+ import { stripSameOriginToPath } from '../chat-nav-resolution';
4
+ import { decideNewTab } from '../decide-new-tab';
5
+ import { executeNavigation } from '../execute-navigation';
6
+
7
+ /**
8
+ * The click path had no tests, which is why every host bug it produced was
9
+ * answered with another defensive layer instead of a case here. These pin the
10
+ * two decisions a click makes — WHICH TAB, and WHO PERFORMS IT.
11
+ */
12
+
13
+ /** jsdom's own origin — the strip is a comparison against it, so it must be real. */
14
+ const ORIGIN = new URL(window.location.href).origin;
15
+
16
+ function runtimeWith(navigation: Partial<ChatRuntime['navigation']> = {}): ChatRuntime {
17
+ return {
18
+ endpoints: { chatStreamUrl: `${ORIGIN}/chat` },
19
+ navigation: { mode: 'host', ...navigation },
20
+ source: 'openframe',
21
+ } as ChatRuntime;
22
+ }
23
+
24
+ function primaryClick() {
25
+ return { preventDefault: vi.fn(), button: 0 };
26
+ }
27
+
28
+ beforeEach(() => {
29
+ window.history.replaceState(null, '', '/dashboard');
30
+ });
31
+
32
+ describe('stripSameOriginToPath', () => {
33
+ it('reduces a same-origin absolute URL to a router-pushable path', () => {
34
+ expect(stripSameOriginToPath(`${ORIGIN}/help-center/guides?slug=a#top`)).toBe('/help-center/guides?slug=a#top');
35
+ });
36
+
37
+ it('strips a CROSS-origin URL too — the caller owns that decision', () => {
38
+ // Deliberate: the hub is one app across several origins and relies on this to
39
+ // keep an absolute in-app href on the host it is running (preview, localhost).
40
+ // The "is this foreign?" question belongs to `runNavigation`, tested below.
41
+ expect(stripSameOriginToPath('https://www.flamingo.run/webinars/x')).toBe('/webinars/x');
42
+ });
43
+
44
+ it('passes relative hrefs through untouched', () => {
45
+ expect(stripSameOriginToPath('/devices/details?id=1')).toBe('/devices/details?id=1');
46
+ });
47
+ });
48
+
49
+ describe('decideNewTab', () => {
50
+ it('keeps a relative href in the same tab', () => {
51
+ expect(decideNewTab({ href: '/help-center/guides', currentSource: 'openframe' })).toBe(false);
52
+ });
53
+
54
+ it('sends a cross-origin href to a new tab', () => {
55
+ expect(decideNewTab({ href: 'https://www.flamingo.run/blog/x', currentSource: 'openframe' })).toBe(true);
56
+ });
57
+
58
+ it('lets a matching targetPlatform beat the origin compare — same tab, cross-origin', () => {
59
+ // The trap openframe hit: the RAG tags every row `openframe`, so a hub URL
60
+ // matched `source` and went same-tab. Pinned because it is surprising, and
61
+ // because hosts that do NOT host that content must pass `targetPlatform: null`.
62
+ expect(
63
+ decideNewTab({
64
+ href: 'https://www.flamingo.run/webinars/x',
65
+ targetPlatform: 'openframe',
66
+ currentSource: 'openframe',
67
+ }),
68
+ ).toBe(false);
69
+ });
70
+
71
+ it('forces a new tab in embed mode regardless of origin', () => {
72
+ expect(decideNewTab({ href: '/anything', currentSource: 'openframe', runtimeMode: 'embed' })).toBe(true);
73
+ });
74
+ });
75
+
76
+ describe('executeNavigation', () => {
77
+ it('hands a same-tab click to the host navigate callback', () => {
78
+ const navigate = vi.fn().mockReturnValue(true);
79
+ const fallbackNavigate = vi.fn();
80
+ const event = primaryClick();
81
+
82
+ const handled = executeNavigation({
83
+ event,
84
+ runtime: runtimeWith({ navigate }),
85
+ href: '/help-center/guides',
86
+ fallbackNavigate,
87
+ });
88
+
89
+ expect(handled).toBe(true);
90
+ expect(event.preventDefault).toHaveBeenCalled();
91
+ expect(navigate).toHaveBeenCalledWith(expect.objectContaining({ href: '/help-center/guides' }));
92
+ expect(fallbackNavigate).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it('falls back to the host router when navigate declines', () => {
96
+ const fallbackNavigate = vi.fn();
97
+
98
+ executeNavigation({
99
+ event: primaryClick(),
100
+ runtime: runtimeWith({ navigate: () => false }),
101
+ href: '/help-center/guides',
102
+ fallbackNavigate,
103
+ });
104
+
105
+ expect(fallbackNavigate).toHaveBeenCalledWith('/help-center/guides');
106
+ });
107
+
108
+ it('opens an absolute SAME-origin href in a new tab', () => {
109
+ // Not a defect: `isCrossOriginUrl` is textual on purpose (SSR-deterministic,
110
+ // see its tests), so "in-app links stay relative" is a real host obligation.
111
+ // Pinned because hosts keep rediscovering it by absolutizing an in-app href
112
+ // and getting a redundant new tab.
113
+ const openExternal = vi.fn();
114
+ const fallbackNavigate = vi.fn();
115
+
116
+ executeNavigation({
117
+ event: primaryClick(),
118
+ runtime: runtimeWith({ openExternal }),
119
+ href: `${ORIGIN}/help-center/guides`,
120
+ fallbackNavigate,
121
+ });
122
+
123
+ expect(openExternal).toHaveBeenCalledWith(`${ORIGIN}/help-center/guides`);
124
+ expect(fallbackNavigate).not.toHaveBeenCalled();
125
+ });
126
+
127
+ it('still strips an absolute SAME-origin href down to a path', () => {
128
+ // The hub's soft-RSC-nav case: one app, several origins, absolute in-app
129
+ // href routed same-tab. It must stay a soft nav, not a full page load.
130
+ const fallbackNavigate = vi.fn();
131
+
132
+ executeNavigation({
133
+ event: primaryClick(),
134
+ runtime: runtimeWith({ navigate: () => false, decideNewTab: () => false }),
135
+ href: `${ORIGIN}/releases/v1?x=1#top`,
136
+ fallbackNavigate,
137
+ });
138
+
139
+ expect(fallbackNavigate).toHaveBeenCalledWith('/releases/v1?x=1#top');
140
+ });
141
+
142
+ it('passes a cross-origin href to the router WHOLE', () => {
143
+ // Regression: it used to arrive origin-stripped and 404 locally.
144
+ const fallbackNavigate = vi.fn();
145
+
146
+ executeNavigation({
147
+ event: primaryClick(),
148
+ runtime: runtimeWith({ navigate: () => false, decideNewTab: () => false }),
149
+ href: 'https://www.flamingo.run/webinars/x',
150
+ fallbackNavigate,
151
+ });
152
+
153
+ expect(fallbackNavigate).toHaveBeenCalledWith('https://www.flamingo.run/webinars/x');
154
+ });
155
+
156
+ it('routes a new-tab decision through openExternal', () => {
157
+ const openExternal = vi.fn();
158
+ const fallbackNavigate = vi.fn();
159
+
160
+ executeNavigation({
161
+ event: primaryClick(),
162
+ runtime: runtimeWith({ openExternal, decideNewTab: () => true }),
163
+ href: 'https://www.flamingo.run/blog/x',
164
+ fallbackNavigate,
165
+ });
166
+
167
+ expect(openExternal).toHaveBeenCalledWith('https://www.flamingo.run/blog/x');
168
+ expect(fallbackNavigate).not.toHaveBeenCalled();
169
+ });
170
+
171
+ it('leaves modifier clicks to the browser', () => {
172
+ const navigate = vi.fn();
173
+ const event = { preventDefault: vi.fn(), button: 0, metaKey: true };
174
+
175
+ const handled = executeNavigation({ event, runtime: runtimeWith({ navigate }), href: '/x' });
176
+
177
+ expect(handled).toBe(false);
178
+ expect(event.preventDefault).not.toHaveBeenCalled();
179
+ expect(navigate).not.toHaveBeenCalled();
180
+ });
181
+
182
+ it('reports an empty href as handled without navigating anywhere', () => {
183
+ const fallbackNavigate = vi.fn();
184
+ const event = primaryClick();
185
+
186
+ expect(executeNavigation({ event, runtime: runtimeWith(), href: '', fallbackNavigate })).toBe(true);
187
+ expect(event.preventDefault).toHaveBeenCalled();
188
+ expect(fallbackNavigate).not.toHaveBeenCalled();
189
+ });
190
+ });
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { makeComposeContentUrl } from '../../../../utils/content-href';
3
- import { resolveFetchedCardHref } from '../resolve-fetched-card-href';
3
+ import { pickFetchedCardHref, resolveFetchedCardHref } from '../resolve-fetched-card-href';
4
4
 
5
5
  const HUB = 'https://www.flamingo.run';
6
6
 
@@ -34,7 +34,7 @@ describe('resolveFetchedCardHref — Mingo cards with no ref metadata', () => {
34
34
  item: { id: 'db-uuid-9', title: 'Deep Google Workspace tenant management' },
35
35
  composeContentUrl: compose,
36
36
  }),
37
- ).toEqual({ href: '/help-center/roadmap?search=86ad3qvv5', targetPlatform: null });
37
+ ).toEqual({ href: '/help-center/roadmap?search=86ad3qvv5', targetPlatform: null, hostOverride: true });
38
38
  });
39
39
 
40
40
  it('hosted type uses the fetched row slug for its in-app detail route', () => {
@@ -152,3 +152,76 @@ describe('makeComposeContentUrl — the new `slug` hint', () => {
152
152
  });
153
153
  });
154
154
  });
155
+
156
+ describe('pickFetchedCardHref — which producer owns a fetched card destination', () => {
157
+ const composedPlain = { href: `${HUB}/tickets/47979601418`, targetPlatform: null };
158
+ const composedOverride = {
159
+ href: '/help-center/tickets?ticket=479&search=479#ticket-479',
160
+ targetPlatform: null,
161
+ hostOverride: true,
162
+ };
163
+
164
+ it('an explicit host override beats the fetched row url', () => {
165
+ // The regression: `hubspot_ticket_self` hydrates from the hub, whose row
166
+ // carries `/tickets?ticket=…` — a path the OpenFrame console also owns, for
167
+ // an unrelated ticket board. The host re-homes the type; that has to win.
168
+ expect(
169
+ pickFetchedCardHref({
170
+ composed: composedOverride,
171
+ itemHref: '/tickets?ticket=479&search=479#ticket-479',
172
+ allowComposed: false,
173
+ }),
174
+ ).toEqual({
175
+ source: 'hostOverride',
176
+ href: '/help-center/tickets?ticket=479&search=479#ticket-479',
177
+ targetPlatform: null,
178
+ });
179
+ });
180
+
181
+ it('the row url still beats a merely SYNTHESIZED href — what noComposedHref guards', () => {
182
+ expect(
183
+ pickFetchedCardHref({ composed: composedPlain, itemHref: '/tickets?ticket=479', allowComposed: false }),
184
+ ).toEqual({ source: 'item', href: '/tickets?ticket=479' });
185
+ // …and even when synthesis is allowed, a row that knows its own url wins.
186
+ expect(
187
+ pickFetchedCardHref({ composed: composedPlain, itemHref: '/tickets?ticket=479', allowComposed: true }),
188
+ ).toEqual({ source: 'item', href: '/tickets?ticket=479' });
189
+ });
190
+
191
+ it('falls through to the composed href only when the registry allows synthesis', () => {
192
+ expect(pickFetchedCardHref({ composed: composedPlain, itemHref: null, allowComposed: true })).toEqual({
193
+ source: 'composed',
194
+ href: `${HUB}/tickets/47979601418`,
195
+ targetPlatform: null,
196
+ });
197
+ expect(pickFetchedCardHref({ composed: composedPlain, itemHref: null, allowComposed: false })).toBeNull();
198
+ });
199
+
200
+ it('nothing to link to → null (card renders unlinked)', () => {
201
+ expect(pickFetchedCardHref({ composed: null, itemHref: null, allowComposed: true })).toBeNull();
202
+ });
203
+
204
+ it('an override with no host seam wired cannot appear — null composed, row url stands', () => {
205
+ expect(pickFetchedCardHref({ composed: null, itemHref: '/tickets?ticket=479', allowComposed: true })).toEqual({
206
+ source: 'item',
207
+ href: '/tickets?ticket=479',
208
+ });
209
+ });
210
+ });
211
+
212
+ describe('makeComposeContentUrl — hostOverride flag', () => {
213
+ it('marks ONLY the explicit overrides branch', () => {
214
+ expect(compose({ type: 'roadmap_item', identifier: 'task-1' })).toEqual({
215
+ href: '/help-center/roadmap?search=task-1',
216
+ targetPlatform: null,
217
+ hostOverride: true,
218
+ });
219
+ // hosted, verbatim-externalUrl and origin-synthesis branches stay unflagged,
220
+ // so a fetched row's own url keeps beating them.
221
+ expect(compose({ type: 'product_release', identifier: 'rel-1', slug: 'v1' }).hostOverride).toBeUndefined();
222
+ expect(
223
+ compose({ type: 'blog_post', identifier: 'b-1', externalUrl: `${HUB}/blog/x` }).hostOverride,
224
+ ).toBeUndefined();
225
+ expect(compose({ type: 'blog_post', identifier: 'b-1' }).hostOverride).toBeUndefined();
226
+ });
227
+ });
@@ -32,9 +32,15 @@ export function isModifierClick(e: {
32
32
  return e.button !== 0 || !!e.metaKey || !!e.ctrlKey || !!e.shiftKey || !!e.altKey;
33
33
  }
34
34
 
35
- /** Strip the origin from a same-origin absolute URL, returning a relative
36
- * path the host's router will treat as in-app navigation. Pass-through
37
- * for already-relative URLs and for cross-origin URLs. */
35
+ /** Strip the origin from an absolute URL, returning a relative path the host's
36
+ * router will treat as in-app navigation. Already-relative URLs pass through.
37
+ *
38
+ * Strips the origin off ANY absolute URL, cross-origin included — the caller
39
+ * decides. The hub relies on that: its platforms are one Next app on several
40
+ * origins, so an absolute `https://openmsp.ai/x` opened same-tab must stay on
41
+ * whatever host is running (prod, a preview deploy, or localhost). Callers that
42
+ * can be handed a genuinely FOREIGN href must check the origin first, or they
43
+ * push a path that 404s on their own — see `runNavigation`. */
38
44
  export function stripSameOriginToPath(href: string): string {
39
45
  if (!href.startsWith('http')) return href;
40
46
  try {