@flamingo-stack/openframe-frontend-core 0.0.510 → 0.0.511

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 (208) hide show
  1. package/dist/{chunk-GE3JUTW2.js → chunk-27OKD7ZV.js} +44 -14
  2. package/dist/chunk-27OKD7ZV.js.map +1 -0
  3. package/dist/{chunk-2RLI5XVI.cjs → chunk-2CE725NN.cjs} +16 -16
  4. package/dist/{chunk-2RLI5XVI.cjs.map → chunk-2CE725NN.cjs.map} +1 -1
  5. package/dist/{chunk-Q4K3TGNP.cjs → chunk-3NECEXDF.cjs} +74 -44
  6. package/dist/chunk-3NECEXDF.cjs.map +1 -0
  7. package/dist/{chunk-RJ3D3PE2.cjs → chunk-4SG55XVA.cjs} +9 -9
  8. package/dist/{chunk-RJ3D3PE2.cjs.map → chunk-4SG55XVA.cjs.map} +1 -1
  9. package/dist/{chunk-SYZFFRVD.cjs → chunk-5BAGE7AI.cjs} +13 -13
  10. package/dist/{chunk-SYZFFRVD.cjs.map → chunk-5BAGE7AI.cjs.map} +1 -1
  11. package/dist/{chunk-6FIDTXYC.js → chunk-7AQSXQEL.js} +3 -3
  12. package/dist/{chunk-6TL5XIAW.cjs → chunk-7YV67K3I.cjs} +119 -119
  13. package/dist/{chunk-6TL5XIAW.cjs.map → chunk-7YV67K3I.cjs.map} +1 -1
  14. package/dist/{chunk-5CDPRCPS.cjs → chunk-A5YZUV6B.cjs} +23 -23
  15. package/dist/{chunk-5CDPRCPS.cjs.map → chunk-A5YZUV6B.cjs.map} +1 -1
  16. package/dist/{chunk-WSAPKKDU.cjs → chunk-C4EUXZSA.cjs} +9 -9
  17. package/dist/{chunk-WSAPKKDU.cjs.map → chunk-C4EUXZSA.cjs.map} +1 -1
  18. package/dist/{chunk-5AZZQQYS.cjs → chunk-DPLXHG7H.cjs} +40 -40
  19. package/dist/{chunk-5AZZQQYS.cjs.map → chunk-DPLXHG7H.cjs.map} +1 -1
  20. package/dist/{chunk-BTUHMCYW.js → chunk-FS3YSNSU.js} +193 -6
  21. package/dist/chunk-FS3YSNSU.js.map +1 -0
  22. package/dist/{chunk-RB2EW6IZ.cjs → chunk-GHXTF35X.cjs} +3231 -3165
  23. package/dist/chunk-GHXTF35X.cjs.map +1 -0
  24. package/dist/{chunk-7VUKIWF6.cjs → chunk-HIV2GIFP.cjs} +3 -3
  25. package/dist/{chunk-7VUKIWF6.cjs.map → chunk-HIV2GIFP.cjs.map} +1 -1
  26. package/dist/{chunk-R4HKB3YD.cjs → chunk-IIPBTWQR.cjs} +22 -22
  27. package/dist/{chunk-R4HKB3YD.cjs.map → chunk-IIPBTWQR.cjs.map} +1 -1
  28. package/dist/{chunk-JMBIGH4H.cjs → chunk-IUZMWMVX.cjs} +83 -83
  29. package/dist/{chunk-JMBIGH4H.cjs.map → chunk-IUZMWMVX.cjs.map} +1 -1
  30. package/dist/{chunk-XJVRAKEX.cjs → chunk-IXCBVJRU.cjs} +56 -33
  31. package/dist/chunk-IXCBVJRU.cjs.map +1 -0
  32. package/dist/{chunk-TBGSQTJK.js → chunk-J4VB4EHW.js} +6 -6
  33. package/dist/{chunk-UWPT23SA.js → chunk-JXMNLH7F.js} +3 -3
  34. package/dist/{chunk-A62C4C4V.cjs → chunk-KI4EGKNV.cjs} +3 -2
  35. package/dist/chunk-KI4EGKNV.cjs.map +1 -0
  36. package/dist/{chunk-RHQ7BJKK.cjs → chunk-LEWQRAZ4.cjs} +73 -73
  37. package/dist/{chunk-RHQ7BJKK.cjs.map → chunk-LEWQRAZ4.cjs.map} +1 -1
  38. package/dist/{chunk-DBR5JLX3.js → chunk-LVAJK2U2.js} +46 -23
  39. package/dist/chunk-LVAJK2U2.js.map +1 -0
  40. package/dist/{chunk-OB7YDOTK.cjs → chunk-NJADHL2G.cjs} +8 -8
  41. package/dist/{chunk-OB7YDOTK.cjs.map → chunk-NJADHL2G.cjs.map} +1 -1
  42. package/dist/{chunk-HCZJZMU7.js → chunk-NKA3O4L6.js} +3 -3
  43. package/dist/{chunk-SRYV4H3M.js → chunk-NNWSGOUP.js} +3 -3
  44. package/dist/{chunk-EYTXD62B.cjs → chunk-OGVIGAMZ.cjs} +232 -45
  45. package/dist/chunk-OGVIGAMZ.cjs.map +1 -0
  46. package/dist/{chunk-EJ3EBYNV.cjs → chunk-OMN7A6RP.cjs} +32 -32
  47. package/dist/{chunk-EJ3EBYNV.cjs.map → chunk-OMN7A6RP.cjs.map} +1 -1
  48. package/dist/{chunk-WNOGR5AA.js → chunk-PRMR6WKC.js} +3 -3
  49. package/dist/{chunk-J2JUAS4C.cjs → chunk-PXQQFRUD.cjs} +11 -11
  50. package/dist/{chunk-J2JUAS4C.cjs.map → chunk-PXQQFRUD.cjs.map} +1 -1
  51. package/dist/{chunk-VDJF4ZAU.js → chunk-RU3SCECX.js} +3 -3
  52. package/dist/{chunk-53JKCFFE.cjs → chunk-RXFBW2EJ.cjs} +12 -12
  53. package/dist/{chunk-53JKCFFE.cjs.map → chunk-RXFBW2EJ.cjs.map} +1 -1
  54. package/dist/{chunk-JOCFPAS6.js → chunk-TNZOUHHN.js} +6 -6
  55. package/dist/{chunk-HX4CJYXL.js → chunk-TR7AMHGB.js} +10 -10
  56. package/dist/{chunk-MLGGF24T.js → chunk-TYVHM67X.js} +8 -8
  57. package/dist/{chunk-Z35QWWJH.cjs → chunk-U4WPFX5K.cjs} +34 -34
  58. package/dist/{chunk-Z35QWWJH.cjs.map → chunk-U4WPFX5K.cjs.map} +1 -1
  59. package/dist/{chunk-M4MKQKFF.js → chunk-UBTOOE55.js} +2 -2
  60. package/dist/{chunk-FEGEC6FY.js → chunk-X7AJ3J42.js} +5170 -5104
  61. package/dist/chunk-X7AJ3J42.js.map +1 -0
  62. package/dist/{chunk-SZMA65OW.js → chunk-XURRAFIB.js} +2 -2
  63. package/dist/{chunk-CTMLY2GN.js → chunk-YGS7BLSN.js} +5 -5
  64. package/dist/{chunk-RG2WRBPT.js → chunk-Z3ADCYC5.js} +2 -2
  65. package/dist/{chunk-2AASLY6D.js → chunk-ZBQV7WH7.js} +3 -3
  66. package/dist/{chunk-4GPWLGKT.js → chunk-ZRILYK5U.js} +2 -1
  67. package/dist/{chunk-LBOWSKGM.js → chunk-ZSIEM7CL.js} +5 -5
  68. package/dist/components/calendar.cjs +4 -4
  69. package/dist/components/calendar.js +3 -3
  70. package/dist/components/case-studies/index.cjs +17 -17
  71. package/dist/components/case-studies/index.js +7 -7
  72. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  73. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  74. package/dist/components/chat/entity-cards/dispatch.d.ts.map +1 -1
  75. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  76. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  77. package/dist/components/chat/index.cjs +13 -7
  78. package/dist/components/chat/index.cjs.map +1 -1
  79. package/dist/components/chat/index.js +12 -6
  80. package/dist/components/chat/remark-mention-chips.d.ts +5 -2
  81. package/dist/components/chat/remark-mention-chips.d.ts.map +1 -1
  82. package/dist/components/chat/utils/discuss-ref-prompt.d.ts +48 -0
  83. package/dist/components/chat/utils/discuss-ref-prompt.d.ts.map +1 -0
  84. package/dist/components/chat/utils/index.d.ts +2 -0
  85. package/dist/components/chat/utils/index.d.ts.map +1 -1
  86. package/dist/components/chat/utils/resolve-fetched-card-href.d.ts +63 -0
  87. package/dist/components/chat/utils/resolve-fetched-card-href.d.ts.map +1 -0
  88. package/dist/components/chat/utils/source-row-cta.d.ts.map +1 -1
  89. package/dist/components/contact/index.cjs +8 -8
  90. package/dist/components/contact/index.js +7 -7
  91. package/dist/components/docs/index.cjs +10 -10
  92. package/dist/components/docs/index.js +9 -9
  93. package/dist/components/embeds/index.cjs +8 -8
  94. package/dist/components/embeds/index.js +7 -7
  95. package/dist/components/faq/index.cjs +9 -9
  96. package/dist/components/faq/index.js +8 -8
  97. package/dist/components/features/index.cjs +7 -7
  98. package/dist/components/features/index.js +6 -6
  99. package/dist/components/help-center-pages/index.cjs +28 -28
  100. package/dist/components/help-center-pages/index.js +19 -19
  101. package/dist/components/icons/index.cjs +3 -3
  102. package/dist/components/icons/index.js +2 -2
  103. package/dist/components/index.cjs +253 -247
  104. package/dist/components/index.cjs.map +1 -1
  105. package/dist/components/index.js +23 -17
  106. package/dist/components/index.js.map +1 -1
  107. package/dist/components/meeting-scheduler/index.cjs +92 -92
  108. package/dist/components/meeting-scheduler/index.js +9 -9
  109. package/dist/components/navigation/index.cjs +7 -7
  110. package/dist/components/navigation/index.js +6 -6
  111. package/dist/components/onboarding-guides/index.cjs +11 -11
  112. package/dist/components/onboarding-guides/index.js +10 -10
  113. package/dist/components/related-content/index.cjs +9 -9
  114. package/dist/components/related-content/index.js +8 -8
  115. package/dist/components/shared/doc-search/resolve-search-result-action.d.ts +11 -3
  116. package/dist/components/shared/doc-search/resolve-search-result-action.d.ts.map +1 -1
  117. package/dist/components/shared/doc-search/use-doc-search.d.ts +7 -0
  118. package/dist/components/shared/doc-search/use-doc-search.d.ts.map +1 -1
  119. package/dist/components/tickets/index.cjs +11 -11
  120. package/dist/components/tickets/index.js +10 -10
  121. package/dist/components/ui/actions-menu.d.ts.map +1 -1
  122. package/dist/components/ui/dropdown-menu.d.ts.map +1 -1
  123. package/dist/components/ui/file-manager/index.cjs +70 -70
  124. package/dist/components/ui/file-manager/index.js +3 -3
  125. package/dist/components/ui/index.cjs +13 -7
  126. package/dist/components/ui/index.cjs.map +1 -1
  127. package/dist/components/ui/index.js +12 -6
  128. package/dist/components/ui/markdown/base-components.d.ts.map +1 -1
  129. package/dist/components/ui/overlay-open-registry.d.ts +50 -0
  130. package/dist/components/ui/overlay-open-registry.d.ts.map +1 -0
  131. package/dist/components/ui/portal-container.d.ts +24 -0
  132. package/dist/components/ui/portal-container.d.ts.map +1 -1
  133. package/dist/hooks/index.cjs +4 -4
  134. package/dist/hooks/index.js +3 -3
  135. package/dist/index.cjs +20 -10
  136. package/dist/index.cjs.map +1 -1
  137. package/dist/index.js +24 -14
  138. package/dist/utils/anchor-id.d.ts +32 -0
  139. package/dist/utils/anchor-id.d.ts.map +1 -0
  140. package/dist/utils/content-href.d.ts +27 -6
  141. package/dist/utils/content-href.d.ts.map +1 -1
  142. package/dist/utils/index.cjs +213 -152
  143. package/dist/utils/index.cjs.map +1 -1
  144. package/dist/utils/index.d.ts +1 -0
  145. package/dist/utils/index.d.ts.map +1 -1
  146. package/dist/utils/index.js +212 -153
  147. package/dist/utils/index.js.map +1 -1
  148. package/dist/utils/list-url.d.ts.map +1 -1
  149. package/dist/utils/same-page-hash-nav.d.ts +17 -6
  150. package/dist/utils/same-page-hash-nav.d.ts.map +1 -1
  151. package/package.json +1 -1
  152. package/src/components/chat/__tests__/remark-mention-chips.test.ts +67 -0
  153. package/src/components/chat/chat-message-enhanced.tsx +3 -2
  154. package/src/components/chat/chat-message-list.tsx +49 -5
  155. package/src/components/chat/embeddable-chat.tsx +19 -2
  156. package/src/components/chat/entity-cards/dispatch.tsx +65 -4
  157. package/src/components/chat/hooks/use-nats-chat-adapter.ts +28 -4
  158. package/src/components/chat/hooks/use-sse-chat-adapter.ts +5 -3
  159. package/src/components/chat/remark-mention-chips.ts +6 -3
  160. package/src/components/chat/utils/__tests__/discuss-ref-prompt.test.ts +67 -0
  161. package/src/components/chat/utils/__tests__/resolve-fetched-card-href.test.ts +156 -0
  162. package/src/components/chat/utils/discuss-ref-prompt.ts +63 -0
  163. package/src/components/chat/utils/index.ts +16 -0
  164. package/src/components/chat/utils/resolve-fetched-card-href.ts +104 -0
  165. package/src/components/chat/utils/source-row-cta.ts +5 -1
  166. package/src/components/shared/doc-search/__tests__/resolve-search-result-action.test.ts +145 -0
  167. package/src/components/shared/doc-search/resolve-search-result-action.ts +46 -10
  168. package/src/components/shared/doc-search/use-doc-search.ts +50 -14
  169. package/src/components/ui/__tests__/overlay-open-registry.test.tsx +87 -0
  170. package/src/components/ui/actions-menu.tsx +65 -2
  171. package/src/components/ui/dropdown-menu.tsx +62 -12
  172. package/src/components/ui/markdown/base-components.tsx +26 -0
  173. package/src/components/ui/overlay-open-registry.tsx +99 -0
  174. package/src/components/ui/portal-container.tsx +31 -0
  175. package/src/utils/__tests__/anchor-id.test.ts +139 -0
  176. package/src/utils/__tests__/content-href.test.ts +74 -0
  177. package/src/utils/__tests__/list-url.test.ts +11 -1
  178. package/src/utils/anchor-id.ts +106 -0
  179. package/src/utils/content-href.ts +113 -13
  180. package/src/utils/index.ts +8 -0
  181. package/src/utils/list-url.ts +6 -1
  182. package/src/utils/same-page-hash-nav.ts +28 -9
  183. package/dist/chunk-A62C4C4V.cjs.map +0 -1
  184. package/dist/chunk-BTUHMCYW.js.map +0 -1
  185. package/dist/chunk-DBR5JLX3.js.map +0 -1
  186. package/dist/chunk-EYTXD62B.cjs.map +0 -1
  187. package/dist/chunk-FEGEC6FY.js.map +0 -1
  188. package/dist/chunk-GE3JUTW2.js.map +0 -1
  189. package/dist/chunk-Q4K3TGNP.cjs.map +0 -1
  190. package/dist/chunk-RB2EW6IZ.cjs.map +0 -1
  191. package/dist/chunk-XJVRAKEX.cjs.map +0 -1
  192. /package/dist/{chunk-6FIDTXYC.js.map → chunk-7AQSXQEL.js.map} +0 -0
  193. /package/dist/{chunk-TBGSQTJK.js.map → chunk-J4VB4EHW.js.map} +0 -0
  194. /package/dist/{chunk-UWPT23SA.js.map → chunk-JXMNLH7F.js.map} +0 -0
  195. /package/dist/{chunk-HCZJZMU7.js.map → chunk-NKA3O4L6.js.map} +0 -0
  196. /package/dist/{chunk-SRYV4H3M.js.map → chunk-NNWSGOUP.js.map} +0 -0
  197. /package/dist/{chunk-WNOGR5AA.js.map → chunk-PRMR6WKC.js.map} +0 -0
  198. /package/dist/{chunk-VDJF4ZAU.js.map → chunk-RU3SCECX.js.map} +0 -0
  199. /package/dist/{chunk-JOCFPAS6.js.map → chunk-TNZOUHHN.js.map} +0 -0
  200. /package/dist/{chunk-HX4CJYXL.js.map → chunk-TR7AMHGB.js.map} +0 -0
  201. /package/dist/{chunk-MLGGF24T.js.map → chunk-TYVHM67X.js.map} +0 -0
  202. /package/dist/{chunk-M4MKQKFF.js.map → chunk-UBTOOE55.js.map} +0 -0
  203. /package/dist/{chunk-SZMA65OW.js.map → chunk-XURRAFIB.js.map} +0 -0
  204. /package/dist/{chunk-CTMLY2GN.js.map → chunk-YGS7BLSN.js.map} +0 -0
  205. /package/dist/{chunk-RG2WRBPT.js.map → chunk-Z3ADCYC5.js.map} +0 -0
  206. /package/dist/{chunk-2AASLY6D.js.map → chunk-ZBQV7WH7.js.map} +0 -0
  207. /package/dist/{chunk-4GPWLGKT.js.map → chunk-ZRILYK5U.js.map} +0 -0
  208. /package/dist/{chunk-LBOWSKGM.js.map → chunk-ZSIEM7CL.js.map} +0 -0
@@ -0,0 +1,156 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { makeComposeContentUrl } from '../../../../utils/content-href'
3
+ import { resolveFetchedCardHref } from '../resolve-fetched-card-href'
4
+
5
+ const HUB = 'https://www.flamingo.run'
6
+
7
+ /** The OpenFrame Help Center seam, verbatim in shape from
8
+ * `openframe-oss-frontend/src/app/(app)/help-center/help-center-content-href.ts`. */
9
+ const compose = makeComposeContentUrl({
10
+ hostedTypes: new Set(['onboarding_guide', 'product_release']),
11
+ contentOrigin: HUB,
12
+ suffixes: {
13
+ onboarding_guide: 'help-center/onboarding-guides',
14
+ product_release: 'help-center/releases',
15
+ blog_post: 'blog',
16
+ case_study: 'case-studies',
17
+ },
18
+ overrides: {
19
+ roadmap_item: (id) => ({
20
+ href: `/help-center/roadmap?search=${encodeURIComponent(id)}`,
21
+ targetPlatform: null,
22
+ }),
23
+ },
24
+ })
25
+
26
+ describe('resolveFetchedCardHref — Mingo cards with no ref metadata', () => {
27
+ it('override type deep-links on the MARKER id, not the fetched row id', () => {
28
+ // The `[card://roadmap_item:86ad3qvv5]` id is the ClickUp task id the
29
+ // roadmap list filters by; the fetched row carries its own db id.
30
+ expect(
31
+ resolveFetchedCardHref({
32
+ contentRefType: 'roadmap_item',
33
+ id: '86ad3qvv5',
34
+ item: { id: 'db-uuid-9', title: 'Deep Google Workspace tenant management' },
35
+ composeContentUrl: compose,
36
+ }),
37
+ ).toEqual({ href: '/help-center/roadmap?search=86ad3qvv5', targetPlatform: null })
38
+ })
39
+
40
+ it('hosted type uses the fetched row slug for its in-app detail route', () => {
41
+ expect(
42
+ resolveFetchedCardHref({
43
+ contentRefType: 'product_release',
44
+ id: 'row-uuid-1',
45
+ item: { id: 'row-uuid-1', slug: 'v1-2-0' },
46
+ composeContentUrl: compose,
47
+ }),
48
+ ).toEqual({ href: '/help-center/releases/v1-2-0', targetPlatform: null })
49
+ })
50
+
51
+ it('hosted type with no slug falls back to the marker id', () => {
52
+ expect(
53
+ resolveFetchedCardHref({
54
+ contentRefType: 'onboarding_guide',
55
+ id: 'guide-7',
56
+ item: { id: 'guide-7' },
57
+ composeContentUrl: compose,
58
+ }),
59
+ ).toEqual({ href: '/help-center/onboarding-guides/guide-7', targetPlatform: null })
60
+ })
61
+
62
+ it('non-hosted type resolves to the content hub', () => {
63
+ expect(
64
+ resolveFetchedCardHref({
65
+ contentRefType: 'case_study',
66
+ id: 'cs-1',
67
+ item: { id: 'cs-1', slug: 'acme-migration' },
68
+ composeContentUrl: compose,
69
+ }),
70
+ ).toEqual({ href: `${HUB}/case-studies/acme-migration`, targetPlatform: null })
71
+ })
72
+
73
+ it('legacy rail-vocab alias resolves to the SAME url as the canonical type', () => {
74
+ // The card registry keys blog entries by `blog_post_existing` (what
75
+ // `buildListUrl` wants). Both spellings must produce one url — the reported
76
+ // split was `/blog/what-is-mdr` vs `/blog_post_existing/bitwarden-…`.
77
+ const canonical = resolveFetchedCardHref({
78
+ contentRefType: 'blog_post',
79
+ id: 'p1',
80
+ item: { id: 'p1', slug: 'bitwarden-review-for-msps' },
81
+ composeContentUrl: compose,
82
+ })
83
+ const alias = resolveFetchedCardHref({
84
+ contentRefType: 'blog_post_existing',
85
+ id: 'p1',
86
+ item: { id: 'p1', slug: 'bitwarden-review-for-msps' },
87
+ composeContentUrl: compose,
88
+ })
89
+ expect(alias).toEqual(canonical)
90
+ expect(alias).toEqual({
91
+ href: `${HUB}/blog/bitwarden-review-for-msps`,
92
+ targetPlatform: null,
93
+ })
94
+ })
95
+
96
+ it('no seam wired → null (card stays unlinked, pre-existing behavior)', () => {
97
+ expect(
98
+ resolveFetchedCardHref({
99
+ contentRefType: 'roadmap_item',
100
+ id: '86ad3qvv5',
101
+ item: { id: '86ad3qvv5' },
102
+ }),
103
+ ).toBeNull()
104
+ })
105
+
106
+ it('guards a missing type / id / malformed item', () => {
107
+ expect(
108
+ resolveFetchedCardHref({ contentRefType: '', id: 'x', item: {}, composeContentUrl: compose }),
109
+ ).toBeNull()
110
+ expect(
111
+ resolveFetchedCardHref({
112
+ contentRefType: 'case_study',
113
+ id: '',
114
+ item: {},
115
+ composeContentUrl: compose,
116
+ }),
117
+ ).toBeNull()
118
+ // A non-object item (or a non-string slug) must not throw — it just loses
119
+ // the slug hint and falls back to the marker id.
120
+ expect(
121
+ resolveFetchedCardHref({
122
+ contentRefType: 'case_study',
123
+ id: 'cs-2',
124
+ item: { slug: 42 },
125
+ composeContentUrl: compose,
126
+ }),
127
+ ).toEqual({ href: `${HUB}/case-studies/cs-2`, targetPlatform: null })
128
+ })
129
+ })
130
+
131
+ describe('makeComposeContentUrl — the new `slug` hint', () => {
132
+ it('externalUrl still wins over the hint (SSE path unchanged)', () => {
133
+ expect(
134
+ compose({
135
+ type: 'product_release',
136
+ identifier: 'row-uuid-1',
137
+ slug: 'from-the-row',
138
+ externalUrl: `${HUB}/releases/from-the-url`,
139
+ }),
140
+ ).toEqual({ href: '/help-center/releases/from-the-url', targetPlatform: null })
141
+ })
142
+
143
+ it('applies to non-hosted types too — hub detail routes are slug-based', () => {
144
+ expect(compose({ type: 'blog_post', identifier: 'id-1', slug: 'hello-world' })).toEqual({
145
+ href: `${HUB}/blog/hello-world`,
146
+ targetPlatform: null,
147
+ })
148
+ })
149
+
150
+ it('no hint → identifier, unchanged for page-view and SSE callers', () => {
151
+ expect(compose({ type: 'blog_post', identifier: 'hello-world' })).toEqual({
152
+ href: `${HUB}/blog/hello-world`,
153
+ targetPlatform: null,
154
+ })
155
+ })
156
+ })
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The ONE place the "Ask Mingo" (discuss) prompt for an inline entity card is
3
+ * composed — shared by every transport so the affordance reads identically
4
+ * wherever it is clicked.
5
+ *
6
+ * ## Why the two transports still differ underneath
7
+ *
8
+ * The prose is UX only. What narrows retrieval differs:
9
+ *
10
+ * - **SSE / guide** — `sendMessage(prompt, { commandOverride: { entityIdFilter:
11
+ * { tableId, id } } })`. Retrieval is strictly primary-key-driven; the
12
+ * sentence is decoration.
13
+ * - **Mingo / NATS** — the agent backend has no entity-id-filtered retrieval
14
+ * (`ContextItemType` covers DEVICE / SCRIPT / TICKET / ORGANIZATION / USER /
15
+ * KB_ARTICLE / POLICY / QUERY / SCHEDULED_SCRIPT — no content types), so the
16
+ * prompt is ALL the agent gets. Hence `includeReference`, which appends the
17
+ * type + id so the agent can still identify the exact row by text.
18
+ *
19
+ * When the backend grows an id-filter for the agent transport, the Mingo callers
20
+ * switch to it and drop `includeReference` — the prose stays put.
21
+ */
22
+
23
+ import { sanitizeTitleForChat } from './slash-dispatch-utils'
24
+
25
+ export interface DiscussPromptRef {
26
+ /** documentType, e.g. `'roadmap_item'`. */
27
+ type: string
28
+ /** Primary key — the id from the `[card://type:id]` marker. */
29
+ id: string
30
+ /** Display title. May be the bare id on a synthetic ref (Mingo). */
31
+ title?: string | null
32
+ }
33
+
34
+ export interface BuildDiscussPromptOptions {
35
+ /**
36
+ * Append `(<type> <id>)` so a transport WITHOUT structured entity filtering
37
+ * still points the agent at the exact row. Default `false` — the SSE path
38
+ * sends `entityIdFilter` and must keep the sentence clean.
39
+ */
40
+ includeReference?: boolean
41
+ }
42
+
43
+ /**
44
+ * `Tell me more about <title>` — plus `(<type> <id>)` when
45
+ * `includeReference` is set and the title alone is ambiguous.
46
+ *
47
+ * Falls back to `this item` when the ref has no usable title, matching the
48
+ * long-standing SSE behavior. A title that is just the id (the synthetic ref a
49
+ * Mingo `[card://…]` marker produces before its row loads) is treated as
50
+ * missing, so the prompt never degrades to `Tell me more about 86ad3qvv5`.
51
+ */
52
+ export function buildDiscussPrompt(
53
+ reference: DiscussPromptRef,
54
+ options: BuildDiscussPromptOptions = {},
55
+ ): string {
56
+ const id = (reference.id ?? '').trim()
57
+ const rawTitle = (reference.title ?? '').trim()
58
+ const usableTitle = rawTitle && rawTitle !== id ? sanitizeTitleForChat(rawTitle) : ''
59
+ const prompt = `Tell me more about ${usableTitle || 'this item'}`
60
+ if (!options.includeReference || !id) return prompt
61
+ // Only worth the noise when the reference adds something the title doesn't.
62
+ return `${prompt} (${reference.type} ${id})`
63
+ }
@@ -196,4 +196,20 @@ export {
196
196
  resolveSourceIcon,
197
197
  } from './source-row-cta'
198
198
 
199
+ // Post-fetch enrichment for the synthetic ChatRef a `[card://type:id]` marker
200
+ // produces when the transport ships no refs metadata (Mingo/NATS).
201
+ export {
202
+ resolveFetchedCardHref,
203
+ readFetchedCardTitle,
204
+ type FetchedCardHrefInput,
205
+ } from './resolve-fetched-card-href'
206
+
207
+ // The ONE "Ask Mingo" prompt builder — shared by the SSE adapter, the NATS
208
+ // adapter, and hosts that inject their own Mingo state (openframe-frontend).
209
+ export {
210
+ buildDiscussPrompt,
211
+ type DiscussPromptRef,
212
+ type BuildDiscussPromptOptions,
213
+ } from './discuss-ref-prompt'
214
+
199
215
  export * from './scripted-stream'
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Post-fetch href fallback for an inline chat card whose `ChatRef` carried no
3
+ * `externalUrl`.
4
+ *
5
+ * ## Why a fetch-mode card can arrive without a URL
6
+ *
7
+ * The two chat transports deliver entity references differently:
8
+ *
9
+ * - **SSE / guide transport** — the server ships a `refs` metadata frame, so
10
+ * each `[card://type:id]` marker expands into a FULL `ChatRef`
11
+ * (`title` / `url` / `targetPlatform` / `sourceRepo`). `resolveSourceRowCTA`
12
+ * then routes that `externalUrl` through the host's `composeContentUrl`
13
+ * seam and the card is clickable.
14
+ * - **Mingo / NATS transport** (`messageData: [{ type: 'GUIDE' | 'TEXT', text }]`)
15
+ * — the body carries bare `[card://type:id]` markers and NOTHING else.
16
+ * `chat-message-enhanced.tsx` synthesizes a minimal `{ type, id, title: id,
17
+ * url: null }` ref so fetch-mode cards can still self-fetch their row, but
18
+ * `resolveSourceRowCTA` has no `externalUrl`, no `path` and no
19
+ * `targetPlatform` to work with → `href: null` → the card renders with its
20
+ * real title and description but goes nowhere when clicked.
21
+ *
22
+ * This resolver closes that gap WITHOUT a second URL vocabulary: it calls the
23
+ * exact same `composeContentUrl` seam the page cards (`resolveContentHref`) and
24
+ * the SSE cards (`resolveSourceRowCTA`) use, so one host config —
25
+ * `hostedTypes` / `suffixes` / `overrides` — governs all three.
26
+ *
27
+ * `identifier` stays the marker's primary key (that is what `overrides` such as
28
+ * roadmap's `?search=<task id>` deep-link on); the row's `slug` rides the
29
+ * separate `slug` hint for hosted types whose detail route is slug-based.
30
+ *
31
+ * Pure — no React, no DOM. The caller owns `safeHref` validation.
32
+ */
33
+
34
+ import type { ComposeContentUrl } from '../../../utils/content-href'
35
+ import { canonicalContentRefType } from '../../../utils/list-url'
36
+
37
+ export interface FetchedCardHrefInput {
38
+ /** Canonical `documentType` for the seam (registry `contentRefType`). */
39
+ contentRefType: string
40
+ /** The marker's primary key — `ChatRef.id`, NOT the fetched row's id. */
41
+ id: string
42
+ /** The row the card fetched. Shape is per-type and opaque here; only
43
+ * `slug` and `platforms` are read, both defensively. */
44
+ item: unknown
45
+ /** Host seam. Absent (unwired runtime) → no fallback, same as today. */
46
+ composeContentUrl?: ComposeContentUrl
47
+ }
48
+
49
+ /** Read `item.slug` when it is a non-empty string. */
50
+ function readSlug(item: unknown): string | null {
51
+ if (!item || typeof item !== 'object') return null
52
+ const slug = (item as { slug?: unknown }).slug
53
+ return typeof slug === 'string' && slug.trim() ? slug.trim() : null
54
+ }
55
+
56
+ /**
57
+ * Human title of a fetched row — `title`, else `name`, else null.
58
+ *
59
+ * A Mingo synthetic ref carries `title: <the marker id>` because that is all
60
+ * the transport shipped. Everything downstream that prints or sends the title
61
+ * (the "Ask Mingo" prompt, host renderers falling back to `ref.title`) would
62
+ * otherwise say `86ad3qvv5`. Once the row loads, this is the real title.
63
+ */
64
+ export function readFetchedCardTitle(item: unknown): string | null {
65
+ if (!item || typeof item !== 'object') return null
66
+ for (const key of ['title', 'name'] as const) {
67
+ const value = (item as Record<string, unknown>)[key]
68
+ if (typeof value === 'string' && value.trim()) return value.trim()
69
+ }
70
+ return null
71
+ }
72
+
73
+ /** Read `item.platforms` when it is an array — the hub composer keys its
74
+ * cross-platform topology off it; the embedder default ignores it. */
75
+ function readPlatforms(item: unknown): Array<{ name?: string }> | undefined {
76
+ if (!item || typeof item !== 'object') return undefined
77
+ const platforms = (item as { platforms?: unknown }).platforms
78
+ return Array.isArray(platforms) ? (platforms as Array<{ name?: string }>) : undefined
79
+ }
80
+
81
+ /**
82
+ * Resolve `{href, targetPlatform}` for a freshly fetched card row, or `null`
83
+ * when the host wired no seam (the caller then keeps the card unlinked, which
84
+ * is the pre-existing behavior).
85
+ */
86
+ export function resolveFetchedCardHref({
87
+ contentRefType,
88
+ id,
89
+ item,
90
+ composeContentUrl,
91
+ }: FetchedCardHrefInput): { href: string; targetPlatform: string | null } | null {
92
+ if (!composeContentUrl || !contentRefType || !id) return null
93
+ return composeContentUrl({
94
+ // The card registry keys some entries by the legacy rail vocabulary
95
+ // (`blog_post_existing`) because that is what `buildListUrl` expects.
96
+ // The seam speaks the CANONICAL vocabulary — a host's `hostedTypes` /
97
+ // `overrides` are written against `blog_post`, so an un-aliased type
98
+ // misses them all and lands on `<origin>/blog_post_existing/<slug>`.
99
+ type: canonicalContentRefType(contentRefType),
100
+ identifier: id,
101
+ slug: readSlug(item),
102
+ platforms: readPlatforms(item),
103
+ })
104
+ }
@@ -29,6 +29,7 @@ import { getBaseUrl } from '../../../utils/cn'
29
29
  import { safeHref } from './compact-card-classes'
30
30
  import type { ChatRef } from '../chat-ref.types'
31
31
  import type { ComposeContentUrl } from '../../../utils/content-href'
32
+ import { canonicalContentRefType } from '../../../utils/list-url'
32
33
 
33
34
  /** Path sanitization — keep alphanumerics, slash, dash, dot, underscore;
34
35
  * strip everything else. Defends against a hostile mapper returning a
@@ -233,7 +234,10 @@ export function resolveSourceRowCTA(
233
234
  // from the externalUrl) and everything else to the externalUrl verbatim —
234
235
  // the SAME resolver the page-view cards use, so chat + page links match.
235
236
  const composed = ctx.composeContentUrl({
236
- type: row.documentType,
237
+ // Canonical vocabulary only — a rail-vocab alias would miss the host's
238
+ // `hostedTypes` / `overrides` and fall through to the seam's default
239
+ // `<origin>/<type>/<id>` branch. Same canonicalizer `buildListUrl` uses.
240
+ type: canonicalContentRefType(row.documentType),
237
241
  identifier: idValue,
238
242
  externalUrl: row.externalUrl,
239
243
  targetPlatform: row.targetPlatform ?? null,
@@ -0,0 +1,145 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { makeComposeContentUrl } from '../../../../utils/content-href'
3
+ import type { SearchResult } from '../../../ui/search-input'
4
+ import { resolveSearchResultAction } from '../resolve-search-result-action'
5
+
6
+ const HUB = 'https://www.flamingo.run'
7
+
8
+ /** A RAG row for a guide the hub owns, shaped like `mapDocSearchResults` emits. */
9
+ const guideRow: SearchResult = {
10
+ id: 'onboarding-guides/deploy-first-device',
11
+ title: 'Deploy your first device',
12
+ path: 'onboarding-guides/deploy-first-device',
13
+ metadata: {
14
+ documentType: 'onboarding_guide',
15
+ externalUrl: `${HUB}/onboarding-guides/deploy-first-device`,
16
+ sourceRepo: 'onboarding-guides',
17
+ id: 'guide-123',
18
+ targetPlatform: 'openframe',
19
+ },
20
+ }
21
+
22
+ /** The OpenFrame Help Center seam: the two hosted types live under
23
+ * `/help-center/...`, list-filter types deep-link with `?search=<id>`. */
24
+ const helpCenterCompose = makeComposeContentUrl({
25
+ hostedTypes: new Set(['onboarding_guide', 'product_release']),
26
+ contentOrigin: HUB,
27
+ suffixes: {
28
+ onboarding_guide: 'help-center/onboarding-guides',
29
+ product_release: 'help-center/releases',
30
+ blog_post: 'blog',
31
+ },
32
+ overrides: {
33
+ delivery_item: (id) => ({ href: `/help-center/bug-fixes?search=${id}`, targetPlatform: null }),
34
+ },
35
+ })
36
+
37
+ describe('resolveSearchResultAction — composeContentUrl seam', () => {
38
+ it('no seam wired → the RAG externalUrl verbatim (legacy behavior)', () => {
39
+ expect(resolveSearchResultAction(guideRow, 'openframe')).toEqual({
40
+ kind: 'navigate-same-tab',
41
+ href: `${HUB}/onboarding-guides/deploy-first-device`,
42
+ })
43
+ })
44
+
45
+ it('hosted type → the host in-app route, slug recovered from externalUrl', () => {
46
+ expect(
47
+ resolveSearchResultAction(guideRow, 'openframe', 'host', helpCenterCompose),
48
+ ).toEqual({
49
+ kind: 'navigate-same-tab',
50
+ href: '/help-center/onboarding-guides/deploy-first-device',
51
+ })
52
+ })
53
+
54
+ it('relative composed href stays same-tab even in embed mode', () => {
55
+ // `decideNewTab` forces new-tab under embed; an in-app path must not be
56
+ // handed to window.open — the short-circuit runs before that decision.
57
+ expect(
58
+ resolveSearchResultAction(guideRow, 'openframe', 'embed', helpCenterCompose),
59
+ ).toEqual({
60
+ kind: 'navigate-same-tab',
61
+ href: '/help-center/onboarding-guides/deploy-first-device',
62
+ })
63
+ })
64
+
65
+ it('override type → the host deep-link, primary key as identifier', () => {
66
+ const row: SearchResult = {
67
+ id: 'delivery-1',
68
+ title: 'Fix the thing',
69
+ metadata: {
70
+ documentType: 'delivery_item',
71
+ externalUrl: `${HUB}/bug-fixes-and-enhancements?search=abc123`,
72
+ sourceRepo: 'clickup-delivery',
73
+ id: 'abc123',
74
+ },
75
+ }
76
+ expect(resolveSearchResultAction(row, 'openframe', 'host', helpCenterCompose)).toEqual({
77
+ kind: 'navigate-same-tab',
78
+ href: '/help-center/bug-fixes?search=abc123',
79
+ })
80
+ })
81
+
82
+ it('non-hosted type → the seam returns externalUrl verbatim, opens out', () => {
83
+ const row: SearchResult = {
84
+ id: 'blog-1',
85
+ title: 'Hello',
86
+ metadata: {
87
+ documentType: 'blog_post',
88
+ externalUrl: `${HUB}/blog/hello`,
89
+ sourceRepo: 'blog-posts',
90
+ id: 'blog-1',
91
+ targetPlatform: 'flamingo',
92
+ },
93
+ }
94
+ expect(resolveSearchResultAction(row, 'openframe', 'host', helpCenterCompose)).toEqual({
95
+ kind: 'navigate-new-tab',
96
+ href: `${HUB}/blog/hello`,
97
+ })
98
+ })
99
+
100
+ it('row without documentType → seam skipped, externalUrl verbatim', () => {
101
+ const row: SearchResult = {
102
+ id: 'x',
103
+ title: 'Untyped',
104
+ metadata: { externalUrl: `${HUB}/somewhere`, targetPlatform: 'openframe' },
105
+ }
106
+ expect(resolveSearchResultAction(row, 'openframe', 'host', helpCenterCompose)).toEqual({
107
+ kind: 'navigate-same-tab',
108
+ href: `${HUB}/somewhere`,
109
+ })
110
+ })
111
+
112
+ it('no externalUrl → unchanged ask-ai / route / noop fallbacks', () => {
113
+ expect(
114
+ resolveSearchResultAction(
115
+ {
116
+ id: 'cap-1',
117
+ title: 'Cap Table',
118
+ metadata: { documentType: 'cap_table', sourceRepo: 'financial-cap-table', id: 'row-9' },
119
+ },
120
+ 'company-hub',
121
+ 'host',
122
+ helpCenterCompose,
123
+ ),
124
+ ).toEqual({
125
+ kind: 'ask-ai',
126
+ detail: {
127
+ source: 'company-hub',
128
+ ref: { type: 'cap_table', id: 'row-9', title: 'Cap Table', url: null },
129
+ },
130
+ })
131
+
132
+ expect(
133
+ resolveSearchResultAction(
134
+ { id: 'doc', title: 'Doc', path: 'repo/architecture/api.md' },
135
+ 'openframe',
136
+ 'host',
137
+ helpCenterCompose,
138
+ ),
139
+ ).toEqual({ kind: 'route', path: 'repo/architecture/api.md' })
140
+
141
+ expect(
142
+ resolveSearchResultAction({ id: 'empty', title: 'Empty' }, 'openframe', 'host', helpCenterCompose),
143
+ ).toEqual({ kind: 'noop' })
144
+ })
145
+ })
@@ -3,8 +3,9 @@
3
3
  * Returns one of five typed actions so the caller is a single switch.
4
4
  *
5
5
  * Resolution order:
6
- * 1. `externalUrl` present → use `decideNewTab` to choose same-tab vs
7
- * new-tab against the row's `targetPlatform`.
6
+ * 1. `externalUrl` present → resolve the href through the host's
7
+ * `composeContentUrl` seam (when wired), then use `decideNewTab` to
8
+ * choose same-tab vs new-tab against the resolved `targetPlatform`.
8
9
  * 2. Row has `id` + `sourceRepo` + `documentType` → synth an Ask-AI
9
10
  * action (entity drill-in via primary key, no URL).
10
11
  * 3. Row has only `path` → legacy navigation fallback.
@@ -16,8 +17,16 @@
16
17
 
17
18
  import type { SearchResult } from '../../ui/search-input'
18
19
  import type { ChatRef } from '../../chat/chat-ref.types'
20
+ import type { ComposeContentUrl } from '../../../utils/content-href'
19
21
  import { decideNewTab } from '../../chat/utils/decide-new-tab'
20
22
 
23
+ /** True for `https://…` and protocol-relative `//…`. A composed href that is
24
+ * NOT absolute is an in-app route the host claimed via `hostedTypes` /
25
+ * `overrides`. */
26
+ function isAbsoluteHref(href: string): boolean {
27
+ return /^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(href)
28
+ }
29
+
21
30
  export type SearchResultAction =
22
31
  | { kind: 'navigate-same-tab'; href: string }
23
32
  | { kind: 'navigate-new-tab'; href: string }
@@ -29,29 +38,56 @@ export function resolveSearchResultAction(
29
38
  result: SearchResult,
30
39
  source: string,
31
40
  runtimeMode?: 'host' | 'embed',
41
+ /** The host's unified content-href seam (`runtime.composeContentUrl`).
42
+ * When wired, an entity row's RAG `externalUrl` is re-resolved through it,
43
+ * so a type the host serves in-app (`hostedTypes`) or deep-links itself
44
+ * (`overrides`) lands on the SAME href the catalog cards and chat cards
45
+ * use. Omitted → the legacy verbatim-`externalUrl` behavior. */
46
+ composeContentUrl?: ComposeContentUrl,
32
47
  ): SearchResultAction {
33
48
  const meta = result.metadata ?? {}
34
49
  const externalUrl = meta.externalUrl as string | undefined
50
+ const rowId = meta.id as string | undefined
51
+ const sourceRepo = meta.sourceRepo as string | undefined
52
+ const documentType = meta.documentType as string | undefined
35
53
  if (externalUrl) {
54
+ const targetPlatform = meta.targetPlatform as string | null | undefined
55
+ // Unified content-href seam — the same call `resolveSourceRowCTA` makes
56
+ // for chat rows. Hosted types relativize the slug out of `externalUrl`;
57
+ // everything else comes back verbatim, so an unwired host (or a row with
58
+ // no `documentType`) keeps today's behavior byte-for-byte.
59
+ const composed =
60
+ composeContentUrl && documentType
61
+ ? composeContentUrl({
62
+ type: documentType,
63
+ identifier: rowId ?? '',
64
+ externalUrl,
65
+ targetPlatform: targetPlatform ?? null,
66
+ })
67
+ : null
68
+ const href = composed?.href || externalUrl
69
+ // A RELATIVE composed href means the host claimed this row as in-app.
70
+ // Short-circuit BEFORE `decideNewTab`: in embed mode that helper forces
71
+ // new-tab unconditionally, which would fire `window.open()` on a path
72
+ // that only resolves inside this app.
73
+ if (composed && !isAbsoluteHref(href)) {
74
+ return { kind: 'navigate-same-tab', href }
75
+ }
36
76
  // Same pure helper `useNavLink` and `useUnifiedNav` call — single
37
77
  // decision rule across cards, chips, and autocomplete rows. Thread
38
78
  // the caller's `source` as `currentSource` so the platform-vs-
39
79
  // platform comparison matches the hub's pre-migration behavior.
40
- const targetPlatform = meta.targetPlatform as string | null | undefined
41
80
  const isNewTab = decideNewTab({
42
- href: externalUrl,
43
- targetPlatform,
81
+ href,
82
+ targetPlatform: composed ? composed.targetPlatform : targetPlatform,
44
83
  surface: 'useUnifiedNav',
45
84
  runtimeMode,
46
85
  currentSource: source,
47
86
  })
48
87
  return isNewTab
49
- ? { kind: 'navigate-new-tab', href: externalUrl }
50
- : { kind: 'navigate-same-tab', href: externalUrl }
88
+ ? { kind: 'navigate-new-tab', href }
89
+ : { kind: 'navigate-same-tab', href }
51
90
  }
52
- const rowId = meta.id as string | undefined
53
- const sourceRepo = meta.sourceRepo as string | undefined
54
- const documentType = meta.documentType as string | undefined
55
91
  if (rowId && sourceRepo && documentType) {
56
92
  return {
57
93
  kind: 'ask-ai',