@flamingo-stack/openframe-frontend-core 0.0.510 → 0.0.511-snapshot.20260804140004
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.
- package/dist/chat-protocol/decode.d.ts.map +1 -1
- package/dist/chat-protocol/frames.d.ts +26 -1
- package/dist/chat-protocol/frames.d.ts.map +1 -1
- package/dist/chat-protocol/index.cjs +24 -0
- package/dist/chat-protocol/index.cjs.map +1 -1
- package/dist/chat-protocol/index.js +24 -0
- package/dist/chat-protocol/index.js.map +1 -1
- package/dist/{chunk-GE3JUTW2.js → chunk-27OKD7ZV.js} +44 -14
- package/dist/chunk-27OKD7ZV.js.map +1 -0
- package/dist/{chunk-RHQ7BJKK.cjs → chunk-2WWDZR4D.cjs} +73 -73
- package/dist/{chunk-RHQ7BJKK.cjs.map → chunk-2WWDZR4D.cjs.map} +1 -1
- package/dist/{chunk-J2JUAS4C.cjs → chunk-2XL7UK5Y.cjs} +11 -11
- package/dist/{chunk-J2JUAS4C.cjs.map → chunk-2XL7UK5Y.cjs.map} +1 -1
- package/dist/{chunk-Q4K3TGNP.cjs → chunk-3NECEXDF.cjs} +74 -44
- package/dist/chunk-3NECEXDF.cjs.map +1 -0
- package/dist/{chunk-JOCFPAS6.js → chunk-4NFDSMB5.js} +6 -6
- package/dist/{chunk-SYZFFRVD.cjs → chunk-5BAGE7AI.cjs} +13 -13
- package/dist/{chunk-SYZFFRVD.cjs.map → chunk-5BAGE7AI.cjs.map} +1 -1
- package/dist/{chunk-53JKCFFE.cjs → chunk-5JBDT2MQ.cjs} +12 -12
- package/dist/{chunk-53JKCFFE.cjs.map → chunk-5JBDT2MQ.cjs.map} +1 -1
- package/dist/{chunk-XJVRAKEX.cjs → chunk-67YDSTH7.cjs} +56 -33
- package/dist/chunk-67YDSTH7.cjs.map +1 -0
- package/dist/{chunk-WNOGR5AA.js → chunk-6LMTBNLN.js} +3 -3
- package/dist/{chunk-6FIDTXYC.js → chunk-7AQSXQEL.js} +3 -3
- package/dist/{chunk-JMBIGH4H.cjs → chunk-7VBEOGCR.cjs} +83 -83
- package/dist/{chunk-JMBIGH4H.cjs.map → chunk-7VBEOGCR.cjs.map} +1 -1
- package/dist/{chunk-5AZZQQYS.cjs → chunk-AWLFJMOJ.cjs} +40 -40
- package/dist/{chunk-5AZZQQYS.cjs.map → chunk-AWLFJMOJ.cjs.map} +1 -1
- package/dist/{chunk-RJ3D3PE2.cjs → chunk-DQDPDUI3.cjs} +9 -9
- package/dist/{chunk-RJ3D3PE2.cjs.map → chunk-DQDPDUI3.cjs.map} +1 -1
- package/dist/{chunk-2RLI5XVI.cjs → chunk-F2YI5N7E.cjs} +16 -16
- package/dist/{chunk-2RLI5XVI.cjs.map → chunk-F2YI5N7E.cjs.map} +1 -1
- package/dist/{chunk-LBOWSKGM.js → chunk-FI35LJ3L.js} +5 -5
- package/dist/{chunk-BTUHMCYW.js → chunk-FS3YSNSU.js} +193 -6
- package/dist/chunk-FS3YSNSU.js.map +1 -0
- package/dist/{chunk-RB2EW6IZ.cjs → chunk-GOFTC2HV.cjs} +3690 -3472
- package/dist/chunk-GOFTC2HV.cjs.map +1 -0
- package/dist/{chunk-CTMLY2GN.js → chunk-HGZPH7S5.js} +5 -5
- package/dist/{chunk-7VUKIWF6.cjs → chunk-HIV2GIFP.cjs} +3 -3
- package/dist/{chunk-7VUKIWF6.cjs.map → chunk-HIV2GIFP.cjs.map} +1 -1
- package/dist/{chunk-DBR5JLX3.js → chunk-IHNUMRMU.js} +46 -23
- package/dist/chunk-IHNUMRMU.js.map +1 -0
- package/dist/{chunk-R4HKB3YD.cjs → chunk-IIPBTWQR.cjs} +22 -22
- package/dist/{chunk-R4HKB3YD.cjs.map → chunk-IIPBTWQR.cjs.map} +1 -1
- package/dist/{chunk-TBGSQTJK.js → chunk-IK2WRYVA.js} +6 -6
- package/dist/{chunk-WSAPKKDU.cjs → chunk-JIW5JI3E.cjs} +9 -9
- package/dist/{chunk-WSAPKKDU.cjs.map → chunk-JIW5JI3E.cjs.map} +1 -1
- package/dist/{chunk-MLGGF24T.js → chunk-JKAX5K7C.js} +8 -8
- package/dist/{chunk-UWPT23SA.js → chunk-JO3NPEK6.js} +3 -3
- package/dist/{chunk-HCZJZMU7.js → chunk-JOFGJYIT.js} +3 -3
- package/dist/{chunk-A62C4C4V.cjs → chunk-KI4EGKNV.cjs} +3 -2
- package/dist/chunk-KI4EGKNV.cjs.map +1 -0
- package/dist/{chunk-OB7YDOTK.cjs → chunk-NJADHL2G.cjs} +8 -8
- package/dist/{chunk-OB7YDOTK.cjs.map → chunk-NJADHL2G.cjs.map} +1 -1
- package/dist/{chunk-EYTXD62B.cjs → chunk-OGVIGAMZ.cjs} +232 -45
- package/dist/chunk-OGVIGAMZ.cjs.map +1 -0
- package/dist/{chunk-HX4CJYXL.js → chunk-OT4RV6WY.js} +10 -10
- package/dist/{chunk-Z35QWWJH.cjs → chunk-RJXI25TJ.cjs} +34 -34
- package/dist/{chunk-Z35QWWJH.cjs.map → chunk-RJXI25TJ.cjs.map} +1 -1
- package/dist/{chunk-6TL5XIAW.cjs → chunk-SG25J6LV.cjs} +119 -119
- package/dist/{chunk-6TL5XIAW.cjs.map → chunk-SG25J6LV.cjs.map} +1 -1
- package/dist/{chunk-M4MKQKFF.js → chunk-UBTOOE55.js} +2 -2
- package/dist/{chunk-FEGEC6FY.js → chunk-V5WLIFKN.js} +5660 -5442
- package/dist/chunk-V5WLIFKN.js.map +1 -0
- package/dist/{chunk-EJ3EBYNV.cjs → chunk-VVXUUBML.cjs} +32 -32
- package/dist/{chunk-EJ3EBYNV.cjs.map → chunk-VVXUUBML.cjs.map} +1 -1
- package/dist/{chunk-SRYV4H3M.js → chunk-VZX3SSFL.js} +3 -3
- package/dist/{chunk-5CDPRCPS.cjs → chunk-WVZHCDB5.cjs} +23 -23
- package/dist/{chunk-5CDPRCPS.cjs.map → chunk-WVZHCDB5.cjs.map} +1 -1
- package/dist/{chunk-VDJF4ZAU.js → chunk-XG7FLAKQ.js} +3 -3
- package/dist/{chunk-SZMA65OW.js → chunk-XURRAFIB.js} +2 -2
- package/dist/{chunk-RG2WRBPT.js → chunk-Z3ADCYC5.js} +2 -2
- package/dist/{chunk-2AASLY6D.js → chunk-ZIQMVAGA.js} +3 -3
- package/dist/{chunk-4GPWLGKT.js → chunk-ZRILYK5U.js} +2 -1
- package/dist/components/calendar.cjs +4 -4
- package/dist/components/calendar.js +3 -3
- package/dist/components/case-studies/index.cjs +17 -17
- package/dist/components/case-studies/index.js +7 -7
- package/dist/components/chat/approval-batch-message.d.ts +2 -2
- package/dist/components/chat/approval-batch-message.d.ts.map +1 -1
- package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
- package/dist/components/chat/chat-message-list.d.ts.map +1 -1
- package/dist/components/chat/entity-cards/deleted-data-card.d.ts +27 -0
- package/dist/components/chat/entity-cards/deleted-data-card.d.ts.map +1 -0
- package/dist/components/chat/entity-cards/dispatch.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-chat-card-item.d.ts +3 -0
- package/dist/components/chat/hooks/use-chat-card-item.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +13 -7
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.js +12 -6
- package/dist/components/chat/remark-mention-chips.d.ts +5 -2
- package/dist/components/chat/remark-mention-chips.d.ts.map +1 -1
- package/dist/components/chat/remark-strip-citations.d.ts +22 -0
- package/dist/components/chat/remark-strip-citations.d.ts.map +1 -0
- package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -1
- package/dist/components/chat/stream/message-mutations.d.ts +49 -5
- package/dist/components/chat/stream/message-mutations.d.ts.map +1 -1
- package/dist/components/chat/types/component.types.d.ts +7 -7
- package/dist/components/chat/types/component.types.d.ts.map +1 -1
- package/dist/components/chat/types/message.types.d.ts +11 -4
- package/dist/components/chat/types/message.types.d.ts.map +1 -1
- package/dist/components/chat/types/unified-chat-state.types.d.ts +4 -1
- package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
- package/dist/components/chat/utils/discuss-ref-prompt.d.ts +48 -0
- package/dist/components/chat/utils/discuss-ref-prompt.d.ts.map +1 -0
- package/dist/components/chat/utils/index.d.ts +2 -0
- package/dist/components/chat/utils/index.d.ts.map +1 -1
- package/dist/components/chat/utils/message-segment-accumulator.d.ts +5 -3
- package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
- package/dist/components/chat/utils/resolve-fetched-card-href.d.ts +63 -0
- package/dist/components/chat/utils/resolve-fetched-card-href.d.ts.map +1 -0
- package/dist/components/chat/utils/source-row-cta.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +8 -8
- package/dist/components/contact/index.js +7 -7
- package/dist/components/docs/index.cjs +10 -10
- package/dist/components/docs/index.js +9 -9
- package/dist/components/embeds/index.cjs +8 -8
- package/dist/components/embeds/index.js +7 -7
- package/dist/components/faq/index.cjs +9 -9
- package/dist/components/faq/index.js +8 -8
- package/dist/components/features/index.cjs +7 -7
- package/dist/components/features/index.js +6 -6
- package/dist/components/help-center-pages/index.cjs +28 -28
- package/dist/components/help-center-pages/index.js +19 -19
- package/dist/components/icons/index.cjs +3 -3
- package/dist/components/icons/index.js +2 -2
- package/dist/components/index.cjs +253 -247
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +23 -17
- package/dist/components/index.js.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +92 -92
- package/dist/components/meeting-scheduler/index.js +9 -9
- package/dist/components/navigation/index.cjs +7 -7
- package/dist/components/navigation/index.js +6 -6
- package/dist/components/onboarding-guides/index.cjs +11 -11
- package/dist/components/onboarding-guides/index.js +10 -10
- package/dist/components/related-content/index.cjs +9 -9
- package/dist/components/related-content/index.js +8 -8
- package/dist/components/shared/doc-search/resolve-search-result-action.d.ts +11 -3
- package/dist/components/shared/doc-search/resolve-search-result-action.d.ts.map +1 -1
- package/dist/components/shared/doc-search/use-doc-search.d.ts +7 -0
- package/dist/components/shared/doc-search/use-doc-search.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +11 -11
- package/dist/components/tickets/index.js +10 -10
- package/dist/components/ui/actions-menu.d.ts.map +1 -1
- package/dist/components/ui/dropdown-menu.d.ts.map +1 -1
- package/dist/components/ui/file-manager/index.cjs +70 -70
- package/dist/components/ui/file-manager/index.js +3 -3
- package/dist/components/ui/index.cjs +13 -7
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +12 -6
- package/dist/components/ui/markdown/base-components.d.ts.map +1 -1
- package/dist/components/ui/overlay-open-registry.d.ts +50 -0
- package/dist/components/ui/overlay-open-registry.d.ts.map +1 -0
- package/dist/components/ui/portal-container.d.ts +24 -0
- package/dist/components/ui/portal-container.d.ts.map +1 -1
- package/dist/hooks/index.cjs +4 -4
- package/dist/hooks/index.js +3 -3
- package/dist/index.cjs +20 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +24 -14
- package/dist/utils/anchor-id.d.ts +32 -0
- package/dist/utils/anchor-id.d.ts.map +1 -0
- package/dist/utils/content-href.d.ts +27 -6
- package/dist/utils/content-href.d.ts.map +1 -1
- package/dist/utils/index.cjs +213 -152
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +212 -153
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/list-url.d.ts.map +1 -1
- package/dist/utils/same-page-hash-nav.d.ts +17 -6
- package/dist/utils/same-page-hash-nav.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/chat-protocol/decode.ts +43 -0
- package/src/chat-protocol/frames.ts +27 -0
- package/src/components/chat/__tests__/remark-mention-chips.test.ts +67 -0
- package/src/components/chat/approval-batch-message.tsx +2 -2
- package/src/components/chat/chat-message-enhanced.tsx +5 -3
- package/src/components/chat/chat-message-list.tsx +49 -5
- package/src/components/chat/embeddable-chat.tsx +19 -2
- package/src/components/chat/entity-cards/deleted-data-card.tsx +61 -0
- package/src/components/chat/entity-cards/dispatch.tsx +205 -102
- package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +1 -1
- package/src/components/chat/hooks/use-chat-card-item.ts +15 -1
- package/src/components/chat/hooks/use-nats-chat-adapter.ts +28 -4
- package/src/components/chat/hooks/use-sse-chat-adapter.ts +57 -32
- package/src/components/chat/remark-mention-chips.ts +6 -3
- package/src/components/chat/remark-strip-citations.ts +39 -0
- package/src/components/chat/stream/__tests__/chat-stream-reducer-approval-batch.test.ts +196 -0
- package/src/components/chat/stream/chat-stream-reducer.ts +67 -2
- package/src/components/chat/stream/message-mutations.ts +170 -40
- package/src/components/chat/types/component.types.ts +9 -7
- package/src/components/chat/types/message.types.ts +14 -4
- package/src/components/chat/types/unified-chat-state.types.ts +4 -1
- package/src/components/chat/utils/__tests__/discuss-ref-prompt.test.ts +67 -0
- package/src/components/chat/utils/__tests__/resolve-fetched-card-href.test.ts +156 -0
- package/src/components/chat/utils/discuss-ref-prompt.ts +63 -0
- package/src/components/chat/utils/index.ts +16 -0
- package/src/components/chat/utils/message-segment-accumulator.ts +24 -29
- package/src/components/chat/utils/resolve-fetched-card-href.ts +104 -0
- package/src/components/chat/utils/source-row-cta.ts +5 -1
- package/src/components/shared/doc-search/__tests__/resolve-search-result-action.test.ts +145 -0
- package/src/components/shared/doc-search/resolve-search-result-action.ts +46 -10
- package/src/components/shared/doc-search/use-doc-search.ts +50 -14
- package/src/components/ui/__tests__/overlay-open-registry.test.tsx +87 -0
- package/src/components/ui/actions-menu.tsx +65 -2
- package/src/components/ui/dropdown-menu.tsx +62 -12
- package/src/components/ui/markdown/base-components.tsx +26 -0
- package/src/components/ui/overlay-open-registry.tsx +99 -0
- package/src/components/ui/portal-container.tsx +31 -0
- package/src/utils/__tests__/anchor-id.test.ts +139 -0
- package/src/utils/__tests__/content-href.test.ts +74 -0
- package/src/utils/__tests__/list-url.test.ts +11 -1
- package/src/utils/anchor-id.ts +106 -0
- package/src/utils/content-href.ts +113 -13
- package/src/utils/index.ts +8 -0
- package/src/utils/list-url.ts +6 -1
- package/src/utils/same-page-hash-nav.ts +28 -9
- package/dist/chunk-A62C4C4V.cjs.map +0 -1
- package/dist/chunk-BTUHMCYW.js.map +0 -1
- package/dist/chunk-DBR5JLX3.js.map +0 -1
- package/dist/chunk-EYTXD62B.cjs.map +0 -1
- package/dist/chunk-FEGEC6FY.js.map +0 -1
- package/dist/chunk-GE3JUTW2.js.map +0 -1
- package/dist/chunk-Q4K3TGNP.cjs.map +0 -1
- package/dist/chunk-RB2EW6IZ.cjs.map +0 -1
- package/dist/chunk-XJVRAKEX.cjs.map +0 -1
- /package/dist/{chunk-JOCFPAS6.js.map → chunk-4NFDSMB5.js.map} +0 -0
- /package/dist/{chunk-WNOGR5AA.js.map → chunk-6LMTBNLN.js.map} +0 -0
- /package/dist/{chunk-6FIDTXYC.js.map → chunk-7AQSXQEL.js.map} +0 -0
- /package/dist/{chunk-LBOWSKGM.js.map → chunk-FI35LJ3L.js.map} +0 -0
- /package/dist/{chunk-CTMLY2GN.js.map → chunk-HGZPH7S5.js.map} +0 -0
- /package/dist/{chunk-TBGSQTJK.js.map → chunk-IK2WRYVA.js.map} +0 -0
- /package/dist/{chunk-MLGGF24T.js.map → chunk-JKAX5K7C.js.map} +0 -0
- /package/dist/{chunk-UWPT23SA.js.map → chunk-JO3NPEK6.js.map} +0 -0
- /package/dist/{chunk-HCZJZMU7.js.map → chunk-JOFGJYIT.js.map} +0 -0
- /package/dist/{chunk-HX4CJYXL.js.map → chunk-OT4RV6WY.js.map} +0 -0
- /package/dist/{chunk-M4MKQKFF.js.map → chunk-UBTOOE55.js.map} +0 -0
- /package/dist/{chunk-SRYV4H3M.js.map → chunk-VZX3SSFL.js.map} +0 -0
- /package/dist/{chunk-VDJF4ZAU.js.map → chunk-XG7FLAKQ.js.map} +0 -0
- /package/dist/{chunk-SZMA65OW.js.map → chunk-XURRAFIB.js.map} +0 -0
- /package/dist/{chunk-RG2WRBPT.js.map → chunk-Z3ADCYC5.js.map} +0 -0
- /package/dist/{chunk-2AASLY6D.js.map → chunk-ZIQMVAGA.js.map} +0 -0
- /package/dist/{chunk-4GPWLGKT.js.map → chunk-ZRILYK5U.js.map} +0 -0
|
@@ -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 →
|
|
7
|
-
*
|
|
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
|
|
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
|
|
50
|
-
: { kind: 'navigate-same-tab', href
|
|
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',
|
|
@@ -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
|
-
[
|
|
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 {
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
>(({
|
|
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
|