@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
@@ -26,6 +26,16 @@
26
26
  * Everything else (debounce, `useChatRuntime` for embed-mode short-
27
27
  * circuit, embed-shim router, the action-resolver + result-mapper) is
28
28
  * now lib-resident.
29
+ *
30
+ * ## Result hrefs go through the content-href seam
31
+ *
32
+ * A row's href is NOT the RAG `externalUrl` verbatim any more: when the
33
+ * host wires `runtime.composeContentUrl` (or passes `composeContentUrl`
34
+ * here), the row is re-resolved through it — the SAME seam the catalog
35
+ * cards and the chat entity cards use. So a host that serves onboarding
36
+ * guides at `/help-center/onboarding-guides/<slug>` gets that path from
37
+ * the dropdown too, instead of bouncing the user to the canonical hub
38
+ * URL. Unwired hosts keep the verbatim-`externalUrl` behavior.
29
39
  */
30
40
 
31
41
  import { useState, useEffect, useCallback } from 'react'
@@ -39,6 +49,7 @@ import {
39
49
  stripSameOriginToPath,
40
50
  NEW_TAB_FEATURES,
41
51
  } from '../../chat/utils/chat-nav-resolution'
52
+ import type { ComposeContentUrl } from '../../../utils/content-href'
42
53
  import type { DocSearchResult } from './types'
43
54
  import { mapDocSearchResults } from './map-doc-search-results'
44
55
  import { resolveSearchResultAction } from './resolve-search-result-action'
@@ -72,6 +83,12 @@ export interface UseDocSearchConfig {
72
83
  * (the hub's reverse-proxy route). Embedders with a different
73
84
  * path can override. */
74
85
  searchEndpoint?: string
86
+ /** Optional content-href seam override. Same fall-back chain as
87
+ * `searchEndpoint`: prop → `runtime.composeContentUrl` → none. Wired,
88
+ * it makes a result row honor the host's `hostedTypes` / `overrides`
89
+ * instead of navigating to the RAG's canonical hub URL — so a row
90
+ * lands where the catalog card for the same entity lands. */
91
+ composeContentUrl?: ComposeContentUrl
75
92
  }
76
93
 
77
94
  export function useDocSearch(config: UseDocSearchConfig) {
@@ -82,6 +99,7 @@ export function useDocSearch(config: UseDocSearchConfig) {
82
99
  tableIds,
83
100
  onInPageSwap,
84
101
  searchEndpoint,
102
+ composeContentUrl,
85
103
  } = config
86
104
  const tableIdsKey = tableIds && tableIds.length > 0 ? tableIds.join(',') : ''
87
105
 
@@ -94,6 +112,11 @@ export function useDocSearch(config: UseDocSearchConfig) {
94
112
  const runtime = useChatRuntime()
95
113
  const resolvedSearchEndpoint =
96
114
  searchEndpoint ?? runtime?.endpoints.docsSearchUrl ?? '/api/docs/search'
115
+ // Content-href seam, same prop → runtime → none chain. Threading it into
116
+ // `resolveSearchResultAction` is what makes the dropdown obey the host's
117
+ // route map; without it the row navigates to the RAG's canonical hub URL
118
+ // even when the host serves that content in-app under a different path.
119
+ const resolvedComposeContentUrl = composeContentUrl ?? runtime?.composeContentUrl
97
120
 
98
121
  const [query, setQuery] = useState('')
99
122
  const [results, setResults] = useState<SearchResult[]>([])
@@ -172,6 +195,7 @@ export function useDocSearch(config: UseDocSearchConfig) {
172
195
  result,
173
196
  source,
174
197
  runtime?.navigation.mode,
198
+ resolvedComposeContentUrl,
175
199
  )
176
200
  // Modifier / non-primary mouse click → force new tab regardless of
177
201
  // same-tab/new-tab decision. The dropdown row is a `<div>`, not an
@@ -188,19 +212,6 @@ export function useDocSearch(config: UseDocSearchConfig) {
188
212
  (typeof modifiers.button === 'number' && modifiers.button !== 0))
189
213
  switch (action.kind) {
190
214
  case 'navigate-same-tab': {
191
- // Embed-mode short-circuit — autocomplete row clicked while
192
- // the chat panel is hosted inside an embedding app.
193
- if (runtime?.navigation.mode === 'embed') {
194
- setKeepOpen(true)
195
- const targetPlatform =
196
- (result.metadata?.targetPlatform as string | null | undefined) ?? null
197
- resolveExternalNavigation({
198
- href: action.href,
199
- targetPlatform,
200
- runtime,
201
- }).open()
202
- return
203
- }
204
215
  if (wantsNewTab) {
205
216
  setKeepOpen(true)
206
217
  window.open(action.href, '_blank', NEW_TAB_FEATURES)
@@ -226,6 +237,23 @@ export function useDocSearch(config: UseDocSearchConfig) {
226
237
  // product-hub) — open in a new tab. Keep dropdown open so the
227
238
  // user can pick another result without re-searching.
228
239
  setKeepOpen(true)
240
+ // Embed-mode short-circuit — the row was clicked while the panel is
241
+ // hosted inside an embedding app, so absolutize against the
242
+ // embedder-supplied content origin and honor its `openExternal`
243
+ // override. Not the only path an embed host takes: a composed
244
+ // RELATIVE href resolves to `navigate-same-tab` above and never
245
+ // reaches `decideNewTab`, which is what lets an embedder keep its
246
+ // own in-app routes in-app.
247
+ if (runtime?.navigation.mode === 'embed') {
248
+ const targetPlatform =
249
+ (result.metadata?.targetPlatform as string | null | undefined) ?? null
250
+ resolveExternalNavigation({
251
+ href: action.href,
252
+ targetPlatform,
253
+ runtime,
254
+ }).open()
255
+ return
256
+ }
229
257
  window.open(action.href, '_blank', NEW_TAB_FEATURES)
230
258
  return
231
259
  case 'ask-ai':
@@ -249,7 +277,15 @@ export function useDocSearch(config: UseDocSearchConfig) {
249
277
  return
250
278
  }
251
279
  },
252
- [onNavigate, source, baseRoute, router, onInPageSwap, runtime],
280
+ [
281
+ onNavigate,
282
+ source,
283
+ baseRoute,
284
+ router,
285
+ onInPageSwap,
286
+ runtime,
287
+ resolvedComposeContentUrl,
288
+ ],
253
289
  )
254
290
 
255
291
  // Reset keepOpen when query changes.
@@ -0,0 +1,87 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { render, act } from '@testing-library/react'
4
+ import { useState } from 'react'
5
+ import {
6
+ OverlayOpenRegistryProvider,
7
+ useReportOverlayOpen,
8
+ } from '../overlay-open-registry'
9
+
10
+ /** Stand-in for `ActionsMenuDropdown` — reports its open state, renders nothing. */
11
+ function Overlay({ open }: { open: boolean }) {
12
+ useReportOverlayOpen(open)
13
+ return null
14
+ }
15
+
16
+ describe('OverlayOpenRegistry', () => {
17
+ it('fires only on 0 → 1 and 1 → 0, not per overlay', () => {
18
+ const onOpenChange = vi.fn()
19
+ function Harness({ a, b }: { a: boolean; b: boolean }) {
20
+ return (
21
+ <OverlayOpenRegistryProvider onOpenChange={onOpenChange}>
22
+ <Overlay open={a} />
23
+ <Overlay open={b} />
24
+ </OverlayOpenRegistryProvider>
25
+ )
26
+ }
27
+ const { rerender } = render(<Harness a={false} b={false} />)
28
+ expect(onOpenChange).not.toHaveBeenCalled()
29
+
30
+ rerender(<Harness a b={false} />)
31
+ expect(onOpenChange.mock.calls).toEqual([[true]])
32
+
33
+ // Second overlay opens while the first is still open — no new signal.
34
+ rerender(<Harness a b />)
35
+ expect(onOpenChange.mock.calls).toEqual([[true]])
36
+
37
+ // First closes, one still open — still no signal.
38
+ rerender(<Harness a={false} b />)
39
+ expect(onOpenChange.mock.calls).toEqual([[true]])
40
+
41
+ rerender(<Harness a={false} b={false} />)
42
+ expect(onOpenChange.mock.calls).toEqual([[true], [false]])
43
+ })
44
+
45
+ it('releases when an open overlay unmounts', () => {
46
+ const onOpenChange = vi.fn()
47
+ function Harness({ mounted }: { mounted: boolean }) {
48
+ return (
49
+ <OverlayOpenRegistryProvider onOpenChange={onOpenChange}>
50
+ {mounted ? <Overlay open /> : null}
51
+ </OverlayOpenRegistryProvider>
52
+ )
53
+ }
54
+ const { rerender } = render(<Harness mounted />)
55
+ expect(onOpenChange.mock.calls).toEqual([[true]])
56
+ rerender(<Harness mounted={false} />)
57
+ expect(onOpenChange.mock.calls).toEqual([[true], [false]])
58
+ })
59
+
60
+ it('is inert without a provider — no throw, no crash', () => {
61
+ expect(() => render(<Overlay open />)).not.toThrow()
62
+ })
63
+
64
+ it('a stable context value keeps overlays from re-rendering on count changes', () => {
65
+ const renderSpy = vi.fn()
66
+ function CountingOverlay({ open }: { open: boolean }) {
67
+ renderSpy()
68
+ useReportOverlayOpen(open)
69
+ return null
70
+ }
71
+ let bump: (() => void) | undefined
72
+ function Harness() {
73
+ const [, setTick] = useState(0)
74
+ bump = () => setTick((t) => t + 1)
75
+ return (
76
+ <OverlayOpenRegistryProvider>
77
+ <CountingOverlay open={false} />
78
+ </OverlayOpenRegistryProvider>
79
+ )
80
+ }
81
+ render(<Harness />)
82
+ const before = renderSpy.mock.calls.length
83
+ act(() => bump?.())
84
+ // One re-render from the parent's own state, not extra churn from context.
85
+ expect(renderSpy.mock.calls.length).toBe(before + 1)
86
+ })
87
+ })
@@ -13,6 +13,16 @@ import {
13
13
  DropdownMenuContent,
14
14
  DropdownMenuTrigger,
15
15
  } from "./dropdown-menu";
16
+ import {
17
+ COLLISION_PADDING_PX,
18
+ useCollisionBoundary,
19
+ usePortalContainer,
20
+ } from "./portal-container";
21
+ import { useReportOverlayOpen } from "./overlay-open-registry";
22
+
23
+ /** Trigger movement (px) that counts as "the user scrolled away" rather than
24
+ * touch-momentum jitter or the sub-pixel settle right after opening. */
25
+ const SCROLL_DISMISS_THRESHOLD_PX = 12;
16
26
 
17
27
  export interface ActionsMenuItemIconAction {
18
28
  icon: React.ReactNode;
@@ -126,6 +136,10 @@ const SecondaryAction: React.FC<{ action: ActionsMenuItemIconAction }> = ({ acti
126
136
  };
127
137
 
128
138
  const MenuItem: React.FC<MenuItemProps> = ({ item, onItemClick }) => {
139
+ // Submenus below portal + collide against the OWNING surface, matching the
140
+ // root menu (`DropdownMenuContent`). Read unconditionally — hooks first.
141
+ const portalContainer = usePortalContainer();
142
+ const collisionBoundary = useCollisionBoundary();
129
143
  const activate = useCallback(() => {
130
144
  if (item.disabled) return;
131
145
  if (item.type === "checkbox") {
@@ -265,9 +279,14 @@ const MenuItem: React.FC<MenuItemProps> = ({ item, onItemClick }) => {
265
279
  >
266
280
  {rowContent}
267
281
  </DropdownMenuPrimitive.SubTrigger>
268
- <DropdownMenuPrimitive.Portal>
282
+ <DropdownMenuPrimitive.Portal container={portalContainer ?? undefined}>
269
283
  <DropdownMenuPrimitive.SubContent
270
284
  sideOffset={4}
285
+ // Confine flip/shift (and the available-height this menu sizes
286
+ // itself by) to the owning surface — see `useCollisionBoundary`.
287
+ collisionBoundary={collisionBoundary ?? undefined}
288
+ collisionPadding={collisionBoundary ? COLLISION_PADDING_PX : undefined}
289
+ hideWhenDetached
271
290
  className="z-[1500] min-w-[256px] max-h-[var(--radix-popper-available-height)] bg-ods-bg border border-ods-border rounded-md shadow-xl overflow-y-auto p-0"
272
291
  >
273
292
  {item.submenu.map((subItem, index) => (
@@ -377,6 +396,50 @@ export const ActionsMenuDropdown: React.FC<ActionsMenuDropdownProps> = ({
377
396
  onChange: onOpenChange,
378
397
  });
379
398
 
399
+ // Tell the surrounding surface an overlay is open, so it can stop moving
400
+ // the ground under it (the chat thread suspends its follow-the-bottom
401
+ // auto-scroll — see `OverlayOpenRegistryProvider`). Inert without a
402
+ // provider, so menus elsewhere are unaffected.
403
+ useReportOverlayOpen(open);
404
+
405
+ // Dismiss once the USER scrolls the trigger away.
406
+ //
407
+ // Two different situations, two different answers. Content moving on its own
408
+ // (a streaming reply) is handled above by suspending the auto-scroll — the
409
+ // menu must survive that, nobody asked for it to close. A deliberate scroll
410
+ // is the opposite: the reader left, and an anchored menu would ride along
411
+ // until it hovers over unrelated chrome. `hideWhenDetached` only kicks in
412
+ // once the trigger is FULLY clipped, so a half-scrolled trigger still drags
413
+ // a visible menu across the header.
414
+ //
415
+ // Scoped deliberately:
416
+ // • only scrolls of containers that actually contain the trigger — a
417
+ // scroll in a neighbouring column is none of our business;
418
+ // • only past `SCROLL_DISMISS_THRESHOLD_PX` of movement, so touch
419
+ // momentum and the ~1px settle after opening don't close the menu the
420
+ // user is reaching for.
421
+ const triggerRef = React.useRef<HTMLButtonElement | null>(null);
422
+ React.useEffect(() => {
423
+ if (!open) return;
424
+ if (typeof document === "undefined") return;
425
+ const trigger = triggerRef.current;
426
+ if (!trigger) return;
427
+ const anchorTop = trigger.getBoundingClientRect().top;
428
+ const onScroll = (event: Event) => {
429
+ const target = event.target as Node | null;
430
+ // `document` fires for the page scroll and contains everything.
431
+ const scrolledTheTrigger =
432
+ target === document ||
433
+ (target instanceof Node && target.contains(trigger));
434
+ if (!scrolledTheTrigger) return;
435
+ const moved = Math.abs(trigger.getBoundingClientRect().top - anchorTop);
436
+ if (moved > SCROLL_DISMISS_THRESHOLD_PX) setOpen(false);
437
+ };
438
+ document.addEventListener("scroll", onScroll, { capture: true, passive: true });
439
+ return () =>
440
+ document.removeEventListener("scroll", onScroll, { capture: true });
441
+ }, [open, setOpen]);
442
+
380
443
  const handleItemClick = useCallback(
381
444
  (item: ActionsMenuItem) => {
382
445
  onItemClick?.(item);
@@ -393,7 +456,7 @@ export const ActionsMenuDropdown: React.FC<ActionsMenuDropdownProps> = ({
393
456
 
394
457
  return (
395
458
  <DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
396
- <DropdownMenuTrigger asChild>
459
+ <DropdownMenuTrigger asChild ref={triggerRef}>
397
460
  {customTrigger ?? (
398
461
  <Button
399
462
  variant="outline"
@@ -5,7 +5,11 @@ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5
5
  import { Check, ChevronRight, Circle } from "lucide-react"
6
6
 
7
7
  import { cn } from "../../utils/cn"
8
- import { usePortalContainer } from "./portal-container"
8
+ import {
9
+ COLLISION_PADDING_PX,
10
+ useCollisionBoundary,
11
+ usePortalContainer,
12
+ } from "./portal-container"
9
13
 
10
14
  const DropdownMenu = DropdownMenuPrimitive.Root
11
15
 
@@ -44,31 +48,77 @@ DropdownMenuSubTrigger.displayName =
44
48
  const DropdownMenuSubContent = React.forwardRef<
45
49
  React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
46
50
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
47
- >(({ className, ...props }, ref) => (
48
- <DropdownMenuPrimitive.SubContent
49
- ref={ref}
50
- className={cn(
51
- "z-50 min-w-[8rem] overflow-hidden rounded-md border border-ods-border bg-ods-card p-1 text-ods-text-primary shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
52
- className
53
- )}
54
- {...props}
55
- />
56
- ))
51
+ >(({ className, collisionBoundary, collisionPadding, hideWhenDetached, ...props }, ref) => {
52
+ // Same container as the root content — `DropdownMenuContent` portals, and a
53
+ // submenu left un-portaled inherits whatever clipping/stacking context its
54
+ // parent sits in instead of the one the host published. (The hand-rolled
55
+ // submenu in `actions-menu` already portals; this keeps the shared primitive
56
+ // consistent with both.)
57
+ const container = usePortalContainer()
58
+ // Same boundary as the root content — a submenu must not escape the surface
59
+ // its parent menu is confined to.
60
+ const boundary = useCollisionBoundary()
61
+ const resolvedBoundary = collisionBoundary ?? boundary ?? undefined
62
+ return (
63
+ <DropdownMenuPrimitive.Portal container={container ?? undefined}>
64
+ <DropdownMenuPrimitive.SubContent
65
+ ref={ref}
66
+ collisionBoundary={resolvedBoundary}
67
+ collisionPadding={collisionPadding ?? (resolvedBoundary ? COLLISION_PADDING_PX : undefined)}
68
+ // Only defaulted ON where a boundary was published (i.e. inside a panel
69
+ // that owns its menus) — see `DropdownMenuContent`.
70
+ hideWhenDetached={hideWhenDetached ?? (resolvedBoundary ? true : undefined)}
71
+ className={cn(
72
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border border-ods-border bg-ods-card p-1 text-ods-text-primary shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
73
+ className
74
+ )}
75
+ {...props}
76
+ />
77
+ </DropdownMenuPrimitive.Portal>
78
+ )
79
+ })
57
80
  DropdownMenuSubContent.displayName =
58
81
  DropdownMenuPrimitive.SubContent.displayName
59
82
 
60
83
  const DropdownMenuContent = React.forwardRef<
61
84
  React.ElementRef<typeof DropdownMenuPrimitive.Content>,
62
85
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
63
- >(({ className, sideOffset = 4, ...props }, ref) => {
86
+ >(({
87
+ className,
88
+ sideOffset = 4,
89
+ collisionBoundary,
90
+ collisionPadding,
91
+ hideWhenDetached,
92
+ ...props
93
+ }, ref) => {
64
94
  // Portal into the active container (e.g. a drawer) so the menu inherits its
65
95
  // stacking context; falls back to `document.body` when none is provided.
66
96
  const container = usePortalContainer()
97
+ // Keep flip/shift inside the surface that owns the menu. Without this Radix
98
+ // collides against the VIEWPORT, so a menu opened near the top of the chat
99
+ // panel grows upward past the panel and lands over the app header. An
100
+ // explicit prop still wins; no provider → viewport, Radix's own default.
101
+ const boundary = useCollisionBoundary()
102
+ const resolvedBoundary = collisionBoundary ?? boundary ?? undefined
67
103
  return (
68
104
  <DropdownMenuPrimitive.Portal container={container ?? undefined}>
69
105
  <DropdownMenuPrimitive.Content
70
106
  ref={ref}
71
107
  sideOffset={sideOffset}
108
+ collisionBoundary={resolvedBoundary}
109
+ collisionPadding={collisionPadding ?? (resolvedBoundary ? COLLISION_PADDING_PX : undefined)}
110
+ // A menu tracks its trigger for as long as it is open. When the trigger
111
+ // scrolls out of its clipping ancestor — a chat card carried away by the
112
+ // thread's auto-scroll while the menu is open — Radix keeps the menu
113
+ // pinned to that off-screen anchor, so it slides to the surface edge and
114
+ // reads as a detached popup floating over unrelated chrome.
115
+ // `hideWhenDetached` makes it disappear with its trigger instead.
116
+ //
117
+ // Defaulted ON only where a boundary was published. This is a SHARED
118
+ // primitive: flipping it on globally would change dismissal behavior for
119
+ // every dropdown in every consumer, and the detached-popup problem it
120
+ // solves is specific to a menu living inside a scrolling panel.
121
+ hideWhenDetached={hideWhenDetached ?? (resolvedBoundary ? true : undefined)}
72
122
  className={cn(
73
123
  "z-50 min-w-[8rem] overflow-hidden rounded-md border border-ods-border bg-ods-card p-1 text-ods-text-primary shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
74
124
  className
@@ -21,6 +21,11 @@ import {
21
21
  useHeadingId,
22
22
  } from './heading-ids';
23
23
  import { slugifyHeadingText } from '../../../utils/markdown-heading-id';
24
+ import {
25
+ getHashTargetElement,
26
+ navigateSamePageHash,
27
+ HUB_HEADER_OFFSET_PX,
28
+ } from '../../../utils/same-page-hash-nav';
24
29
 
25
30
  /**
26
31
  * True when an image `src` is worth rendering. Shared by the base `img` and
@@ -284,12 +289,33 @@ export function buildBaseComponents({
284
289
  );
285
290
  }
286
291
 
292
+ // In-page anchor. Doc TOCs are authored against GitHub's slugger
293
+ // (`## 📚 Table of Contents` → `#-table-of-contents`) while our heading
294
+ // ids trim the emoji's leftover hyphen, so the raw href resolves to
295
+ // nothing and the browser silently ignores the click. Resolve through
296
+ // `getHashTargetElement` — the same resolver deep links use — then hand
297
+ // the real id to the canonical hash-nav helper, which also lands the
298
+ // heading BELOW the sticky header instead of under it, matching how the
299
+ // "On this page" rail scrolls.
300
+ const isInPageAnchor = typeof href === 'string' && href.startsWith('#') && href.length > 1;
301
+ const handleAnchorClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
302
+ // Let modifier / non-primary clicks keep their native behavior.
303
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
304
+ const target = getHashTargetElement(href.slice(1));
305
+ // Nothing matched anywhere in the document → leave the browser's
306
+ // default alone rather than swallowing a click that isn't ours.
307
+ if (!target?.id) return;
308
+ e.preventDefault();
309
+ navigateSamePageHash(`#${target.id}`, { headerOffset: HUB_HEADER_OFFSET_PX });
310
+ };
311
+
287
312
  return (
288
313
  <a
289
314
  href={href}
290
315
  className={`text-ods-accent no-underline relative transition-colors duration-200 hover:after:w-full after:content-[''] after:absolute after:w-0 after:h-0.5 after:-bottom-0.5 after:left-0 after:bg-ods-accent after:transition-all after:duration-300 ${linkClassName || ''}`}
291
316
  target={href?.startsWith('http') ? '_blank' : undefined}
292
317
  rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
318
+ onClick={isInPageAnchor ? handleAnchorClick : undefined}
293
319
  >
294
320
  {children}
295
321
  </a>
@@ -0,0 +1,99 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * A surface-scoped count of overlays (⋯ menus, popovers) currently open INSIDE
5
+ * that surface, so the surface can suspend behaviour that would move the ground
6
+ * under them.
7
+ *
8
+ * ## Why
9
+ *
10
+ * An open menu stays anchored to its trigger. In a chat thread the ground moves
11
+ * on its own: a streaming reply grows the transcript and the auto-scroll follows
12
+ * the bottom, carrying the trigger — and its menu — upward until the menu is
13
+ * pinned to an off-screen anchor at the panel edge, visually detached from the
14
+ * card it belongs to.
15
+ *
16
+ * The honest fix is not to dismiss the menu on every scroll event (that breaks
17
+ * touch, where momentum scrolling fires constantly) but to stop pulling the
18
+ * ground: while an overlay is open the thread stops chasing the bottom, exactly
19
+ * how ChatGPT/Claude stop chasing once you interact with the transcript. When
20
+ * the overlay closes, the thread catches up if it was at the bottom before.
21
+ *
22
+ * ## Wiring
23
+ *
24
+ * Surface: wrap the region in `<OverlayOpenRegistryProvider onOpenChange={…}>`.
25
+ * Overlay: call `useReportOverlayOpen(open)`. No provider → the hook is inert,
26
+ * so overlays outside such a surface behave exactly as before.
27
+ */
28
+
29
+ import * as React from 'react'
30
+
31
+ export interface OverlayOpenRegistry {
32
+ /** Register one open overlay; call the returned fn to release it. */
33
+ acquire: () => () => void
34
+ }
35
+
36
+ export const OverlayOpenRegistryContext =
37
+ React.createContext<OverlayOpenRegistry | null>(null)
38
+
39
+ /** Read the surrounding registry (or `null` when there is no provider). */
40
+ export function useOverlayOpenRegistry(): OverlayOpenRegistry | null {
41
+ return React.useContext(OverlayOpenRegistryContext)
42
+ }
43
+
44
+ /**
45
+ * Report this overlay's open state to the surrounding surface. Safe to call
46
+ * unconditionally — with no provider it does nothing.
47
+ */
48
+ export function useReportOverlayOpen(open: boolean): void {
49
+ const registry = useOverlayOpenRegistry()
50
+ React.useEffect(() => {
51
+ if (!open || !registry) return
52
+ return registry.acquire()
53
+ }, [open, registry])
54
+ }
55
+
56
+ export interface OverlayOpenRegistryProviderProps {
57
+ /** Fires on 0 → 1 and 1 → 0 transitions only, not on every acquire. */
58
+ onOpenChange?: (hasOpenOverlay: boolean) => void
59
+ children: React.ReactNode
60
+ }
61
+
62
+ /**
63
+ * Owns the count for one surface. The context value is referentially stable, so
64
+ * mounting this provider does not re-render overlays when the count changes —
65
+ * only the surface's own `onOpenChange` callback fires.
66
+ */
67
+ export function OverlayOpenRegistryProvider({
68
+ onOpenChange,
69
+ children,
70
+ }: OverlayOpenRegistryProviderProps) {
71
+ const countRef = React.useRef(0)
72
+ const onOpenChangeRef = React.useRef(onOpenChange)
73
+ onOpenChangeRef.current = onOpenChange
74
+
75
+ const registry = React.useMemo<OverlayOpenRegistry>(
76
+ () => ({
77
+ acquire: () => {
78
+ countRef.current += 1
79
+ if (countRef.current === 1) onOpenChangeRef.current?.(true)
80
+ let released = false
81
+ return () => {
82
+ // Guard against a double release (StrictMode double-invokes effect
83
+ // cleanups in development) driving the count negative.
84
+ if (released) return
85
+ released = true
86
+ countRef.current -= 1
87
+ if (countRef.current === 0) onOpenChangeRef.current?.(false)
88
+ }
89
+ },
90
+ }),
91
+ [],
92
+ )
93
+
94
+ return (
95
+ <OverlayOpenRegistryContext.Provider value={registry}>
96
+ {children}
97
+ </OverlayOpenRegistryContext.Provider>
98
+ )
99
+ }
@@ -26,3 +26,34 @@ export const PortalContainerContext = React.createContext<HTMLElement | null>(
26
26
  export function usePortalContainer(): HTMLElement | null {
27
27
  return React.useContext(PortalContainerContext)
28
28
  }
29
+
30
+ /**
31
+ * The element Radix overlays should treat as their COLLISION boundary — the
32
+ * surface they must stay inside of.
33
+ *
34
+ * Separate from {@link PortalContainerContext} on purpose: the portal target is
35
+ * a `display: contents` node (no box, so it can't be measured), while a
36
+ * collision boundary must be a real, laid-out element.
37
+ *
38
+ * Why it matters: Radix positions overlays `position: fixed` and, by default,
39
+ * flips/shifts them against the VIEWPORT. A menu opened near the top of a
40
+ * panel therefore happily grows upward past the panel's edge and lands on top
41
+ * of the app header — visually detached from the surface it belongs to. Handing
42
+ * Radix the panel element instead keeps flip/shift (and the
43
+ * `--radix-popper-available-height` a menu sizes itself by) inside that panel.
44
+ *
45
+ * Default `null` → viewport, i.e. Radix's own default, unchanged everywhere a
46
+ * provider isn't present.
47
+ */
48
+ export const CollisionBoundaryContext = React.createContext<HTMLElement | null>(
49
+ null,
50
+ )
51
+
52
+ /** Read the active collision boundary (or `null` for the viewport). */
53
+ export function useCollisionBoundary(): HTMLElement | null {
54
+ return React.useContext(CollisionBoundaryContext)
55
+ }
56
+
57
+ /** Padding (px) kept between an overlay and its boundary edge. Matches the
58
+ * panel's own gutter so a shifted menu never sits flush against the edge. */
59
+ export const COLLISION_PADDING_PX = 8