From d976d83d58120b33975042ee7893da024af2a4c6 Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Wed, 24 Sep 2025 17:04:24 +0100 Subject: [PATCH 1/4] track opening and closing the assistant --- .../src/components/assistant-chat.tsx | 27 ++++++++++- .../src/components/drawer-portal.tsx | 46 ++++++++++++++++--- .../compass-telemetry/src/telemetry-events.ts | 26 +++++++++++ 3 files changed, 91 insertions(+), 8 deletions(-) diff --git a/packages/compass-assistant/src/components/assistant-chat.tsx b/packages/compass-assistant/src/components/assistant-chat.tsx index b3fa99f467e..d52bc4b8240 100644 --- a/packages/compass-assistant/src/components/assistant-chat.tsx +++ b/packages/compass-assistant/src/components/assistant-chat.tsx @@ -18,10 +18,12 @@ import { LgChatChatDisclaimer, Link, Icon, + useDrawerState, } from '@mongodb-js/compass-components'; import { ConfirmationMessage } from './confirmation-message'; import { useTelemetry } from '@mongodb-js/compass-telemetry/provider'; import { NON_GENUINE_WARNING_MESSAGE } from '../preset-messages'; +import { ASSISTANT_DRAWER_ID } from '../compass-assistant-provider'; const { DisclaimerText } = LgChatChatDisclaimer; const { ChatWindow } = LgChatChatWindow; @@ -204,6 +206,7 @@ export const AssistantChat: React.FunctionComponent = ({ const track = useTelemetry(); const darkMode = useDarkMode(); + const { currentDrawerTab } = useDrawerState(); const { ensureOptInAndSend } = useContext(AssistantActionsContext); const { messages, status, error, clearError, setMessages } = useChat({ chat, @@ -326,9 +329,31 @@ export const AssistantChat: React.FunctionComponent = ({ void ensureOptInAndSend?.(undefined, {}, () => {}); } }, - [ensureOptInAndSend, setMessages] + [ensureOptInAndSend, setMessages, track] ); + const prevCurrentDrawerTabRef = React.useRef(null); + + useEffect(() => { + if (currentDrawerTab === prevCurrentDrawerTabRef.current) { + // ignore unless it changed + return; + } + + if (currentDrawerTab === ASSISTANT_DRAWER_ID) { + track('Assistant Opened', {}); + } + + if ( + currentDrawerTab !== ASSISTANT_DRAWER_ID && + prevCurrentDrawerTabRef.current === ASSISTANT_DRAWER_ID + ) { + track('Assistant Closed', {}); + } + + prevCurrentDrawerTabRef.current = currentDrawerTab; + }, [currentDrawerTab, track]); + return (
(() => {}); +type DrawerCurrentTabStateContextValue = string | null; + +type DrawerSetCurrentTabContextValue = (currentTab: string | null) => void; + +const DrawerCurrentTabStateContext = + React.createContext(null); + +const DrawerSetCurrentTabContext = + React.createContext(() => {}); + const DrawerActionsContext = React.createContext({ current: { openDrawer: () => undefined, @@ -110,6 +120,8 @@ export const DrawerContentProvider: React.FunctionComponent = ({ const [drawerState, setDrawerState] = useState([]); const [drawerOpenState, setDrawerOpenState] = useState(false); + const [drawerCurrentTab, setDrawerCurrentTab] = + useState(null); const drawerActions = useRef({ openDrawer: () => undefined, closeDrawer: () => undefined, @@ -139,9 +151,13 @@ export const DrawerContentProvider: React.FunctionComponent = ({ - - {children} - + + + + {children} + + + @@ -152,11 +168,20 @@ const DrawerContextGrabber: React.FunctionComponent = ({ children }) => { const drawerToolbarContext = useDrawerToolbarContext(); const actions = useContext(DrawerActionsContext); const openStateSetter = useContext(DrawerSetOpenStateContext); + const currentTabSetter = useContext(DrawerSetCurrentTabContext); actions.current.openDrawer = drawerToolbarContext.openDrawer; actions.current.closeDrawer = drawerToolbarContext.closeDrawer; + useEffect(() => { openStateSetter(drawerToolbarContext.isDrawerOpen); }, [drawerToolbarContext.isDrawerOpen, openStateSetter]); + + useEffect(() => { + const currentTab = + drawerToolbarContext.getActiveDrawerContent()?.id ?? null; + currentTabSetter(currentTab); + }, [drawerToolbarContext, currentTabSetter]); + return <>{children}; }; @@ -463,12 +488,19 @@ export function useDrawerActions() { export const useDrawerState = () => { const drawerOpenStateContext = useContext(DrawerOpenStateContext); + const drawerCurrentTabStateContext = useContext(DrawerCurrentTabStateContext); const drawerState = useContext(DrawerStateContext); + + const isDrawerOpen = + drawerOpenStateContext && + // the second check is a workaround, because LG doesn't set isDrawerOpen to false when it's empty + drawerState.length > 0; + + const currentDrawerTab = isDrawerOpen ? drawerCurrentTabStateContext : null; + return { - isDrawerOpen: - drawerOpenStateContext && - // the second check is a workaround, because LG doesn't set isDrawerOpen to false when it's empty - drawerState.length > 0, + isDrawerOpen, + currentDrawerTab, }; }; diff --git a/packages/compass-telemetry/src/telemetry-events.ts b/packages/compass-telemetry/src/telemetry-events.ts index dcfe8cf256e..c39bce17e91 100644 --- a/packages/compass-telemetry/src/telemetry-events.ts +++ b/packages/compass-telemetry/src/telemetry-events.ts @@ -1458,6 +1458,30 @@ type IndexDroppedEvent = ConnectionScopedEvent<{ }; }>; +/** + * This event is fired when user opens the assistant chat. Either by switching + * to it via the drawer toolbar or by opening the drawer and the first tab is + * the assistant. + * + * @category Gen AI + */ +type AssistantOpenedEvent = CommonEvent<{ + name: 'Assistant Opened'; + payload: Record; +}>; + +/** + * This event is fired when user closes the assistant chat. Either by switching + * to another tab via the drawer toolbar or by closing the drawer when the + * active tab is the assistant. + * + * @category Gen AI + */ +type AssistantClosedEvent = CommonEvent<{ + name: 'Assistant Closed'; + payload: Record; +}>; + /** * This event is fired when user enters a prompt in the assistant chat * and hits "enter". @@ -3116,6 +3140,8 @@ export type TelemetryEvent = | AggregationTimedOutEvent | AggregationUseCaseAddedEvent | AggregationUseCaseSavedEvent + | AssistantOpenedEvent + | AssistantClosedEvent | AssistantPromptSubmittedEvent | AssistantResponseFailedEvent | AssistantFeedbackSubmittedEvent From 20ac43bd1f479d0b7724bb95bbdf28d3e2cdc213 Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Thu, 25 Sep 2025 16:05:40 +0100 Subject: [PATCH 2/4] rather make the drawer section tracking part of the drawer for all drawer sections --- .../src/components/assistant-chat.tsx | 25 ---------- .../compass-components-provider.tsx | 10 +++- .../src/components/drawer-portal.tsx | 49 +++++++++++++------ .../compass-telemetry/src/telemetry-events.ts | 28 ++++++----- packages/compass-web/src/entrypoint.tsx | 10 ++++ packages/compass/src/app/components/home.tsx | 6 +++ 6 files changed, 76 insertions(+), 52 deletions(-) diff --git a/packages/compass-assistant/src/components/assistant-chat.tsx b/packages/compass-assistant/src/components/assistant-chat.tsx index d52bc4b8240..a5f8aad9146 100644 --- a/packages/compass-assistant/src/components/assistant-chat.tsx +++ b/packages/compass-assistant/src/components/assistant-chat.tsx @@ -18,12 +18,10 @@ import { LgChatChatDisclaimer, Link, Icon, - useDrawerState, } from '@mongodb-js/compass-components'; import { ConfirmationMessage } from './confirmation-message'; import { useTelemetry } from '@mongodb-js/compass-telemetry/provider'; import { NON_GENUINE_WARNING_MESSAGE } from '../preset-messages'; -import { ASSISTANT_DRAWER_ID } from '../compass-assistant-provider'; const { DisclaimerText } = LgChatChatDisclaimer; const { ChatWindow } = LgChatChatWindow; @@ -206,7 +204,6 @@ export const AssistantChat: React.FunctionComponent = ({ const track = useTelemetry(); const darkMode = useDarkMode(); - const { currentDrawerTab } = useDrawerState(); const { ensureOptInAndSend } = useContext(AssistantActionsContext); const { messages, status, error, clearError, setMessages } = useChat({ chat, @@ -332,28 +329,6 @@ export const AssistantChat: React.FunctionComponent = ({ [ensureOptInAndSend, setMessages, track] ); - const prevCurrentDrawerTabRef = React.useRef(null); - - useEffect(() => { - if (currentDrawerTab === prevCurrentDrawerTabRef.current) { - // ignore unless it changed - return; - } - - if (currentDrawerTab === ASSISTANT_DRAWER_ID) { - track('Assistant Opened', {}); - } - - if ( - currentDrawerTab !== ASSISTANT_DRAWER_ID && - prevCurrentDrawerTabRef.current === ASSISTANT_DRAWER_ID - ) { - track('Assistant Closed', {}); - } - - prevCurrentDrawerTabRef.current = currentDrawerTab; - }, [currentDrawerTab, track]); - return (
void; +} & { + onDrawerSectionOpen?: (drawerSectionId: string) => void; + onDrawerSectionHide?: (drawerSectionId: string) => void; } & React.ComponentProps; const darkModeMediaQuery = (() => { @@ -119,6 +122,8 @@ export const CompassComponentsProvider = ({ onNextGuideCueGroup, onContextMenuOpen, onContextMenuItemClick, + onDrawerSectionOpen, + onDrawerSectionHide, utmSource, utmMedium, stackedElementsZIndex, @@ -149,7 +154,10 @@ export const CompassComponentsProvider = ({ darkMode={darkMode} popoverPortalContainer={popoverPortalContainer} > - + ({ * ) * } */ -export const DrawerContentProvider: React.FunctionComponent = ({ - children, -}) => { +export const DrawerContentProvider: React.FunctionComponent<{ + onDrawerSectionOpen?: (drawerSectionId: string) => void; + onDrawerSectionHide?: (drawerSectionId: string) => void; + children?: React.ReactNode; +}> = ({ onDrawerSectionOpen, onDrawerSectionHide, children }) => { const [drawerState, setDrawerState] = useState([]); const [drawerOpenState, setDrawerOpenState] = useState(false); @@ -147,6 +149,31 @@ export const DrawerContentProvider: React.FunctionComponent = ({ }, }); + const prevDrawerCurrentTabRef = React.useRef(null); + + useEffect(() => { + if (drawerCurrentTab === prevDrawerCurrentTabRef.current) { + // ignore unless it changed + return; + } + + if ( + drawerCurrentTab && + drawerCurrentTab !== prevDrawerCurrentTabRef.current + ) { + onDrawerSectionOpen?.(drawerCurrentTab); + } + + if ( + prevDrawerCurrentTabRef.current && + drawerCurrentTab !== prevDrawerCurrentTabRef.current + ) { + onDrawerSectionHide?.(prevDrawerCurrentTabRef.current); + } + + prevDrawerCurrentTabRef.current = drawerCurrentTab; + }, [drawerCurrentTab, onDrawerSectionHide, onDrawerSectionOpen]); + return ( @@ -179,6 +206,7 @@ const DrawerContextGrabber: React.FunctionComponent = ({ children }) => { useEffect(() => { const currentTab = drawerToolbarContext.getActiveDrawerContent()?.id ?? null; + currentTabSetter(currentTab); }, [drawerToolbarContext, currentTabSetter]); @@ -488,19 +516,12 @@ export function useDrawerActions() { export const useDrawerState = () => { const drawerOpenStateContext = useContext(DrawerOpenStateContext); - const drawerCurrentTabStateContext = useContext(DrawerCurrentTabStateContext); const drawerState = useContext(DrawerStateContext); - - const isDrawerOpen = - drawerOpenStateContext && - // the second check is a workaround, because LG doesn't set isDrawerOpen to false when it's empty - drawerState.length > 0; - - const currentDrawerTab = isDrawerOpen ? drawerCurrentTabStateContext : null; - return { - isDrawerOpen, - currentDrawerTab, + isDrawerOpen: + drawerOpenStateContext && + // the second check is a workaround, because LG doesn't set isDrawerOpen to false when it's empty + drawerState.length > 0, }; }; diff --git a/packages/compass-telemetry/src/telemetry-events.ts b/packages/compass-telemetry/src/telemetry-events.ts index c39bce17e91..16fe32e09fa 100644 --- a/packages/compass-telemetry/src/telemetry-events.ts +++ b/packages/compass-telemetry/src/telemetry-events.ts @@ -1459,27 +1459,31 @@ type IndexDroppedEvent = ConnectionScopedEvent<{ }>; /** - * This event is fired when user opens the assistant chat. Either by switching + * This event is fired when user opens a drawer section. Either by switching * to it via the drawer toolbar or by opening the drawer and the first tab is - * the assistant. + * this drawer section. * * @category Gen AI */ -type AssistantOpenedEvent = CommonEvent<{ - name: 'Assistant Opened'; - payload: Record; +type DrawerSectionOpenedEvent = CommonEvent<{ + name: 'Drawer Section Opened'; + payload: { + sectionId: string; + }; }>; /** - * This event is fired when user closes the assistant chat. Either by switching + * This event is fired when user closes a drawer section. Either by switching * to another tab via the drawer toolbar or by closing the drawer when the - * active tab is the assistant. + * active tab is this drawer section. * * @category Gen AI */ -type AssistantClosedEvent = CommonEvent<{ - name: 'Assistant Closed'; - payload: Record; +type DrawerSectionClosedEvent = CommonEvent<{ + name: 'Drawer Section Closed'; + payload: { + sectionId: string; + }; }>; /** @@ -3140,8 +3144,6 @@ export type TelemetryEvent = | AggregationTimedOutEvent | AggregationUseCaseAddedEvent | AggregationUseCaseSavedEvent - | AssistantOpenedEvent - | AssistantClosedEvent | AssistantPromptSubmittedEvent | AssistantResponseFailedEvent | AssistantFeedbackSubmittedEvent @@ -3200,6 +3202,8 @@ export type TelemetryEvent = | DocumentDeletedEvent | DocumentInsertedEvent | DocumentUpdatedEvent + | DrawerSectionOpenedEvent + | DrawerSectionClosedEvent | EditorTypeChangedEvent | ErrorFetchingAttributesEvent | ExplainPlanExecutedEvent diff --git a/packages/compass-web/src/entrypoint.tsx b/packages/compass-web/src/entrypoint.tsx index 68cd0522b57..e43725cc09b 100644 --- a/packages/compass-web/src/entrypoint.tsx +++ b/packages/compass-web/src/entrypoint.tsx @@ -440,6 +440,16 @@ const CompassWeb = ({ item_label: item.label, }); }} + onDrawerSectionOpen={(drawerSectionId) => { + onTrackRef.current?.('Drawer Section Opened', { + sectionId: drawerSectionId, + }); + }} + onDrawerSectionHide={(drawerSectionId) => { + onTrackRef.current?.('Drawer Section Closed', { + sectionId: drawerSectionId, + }); + }} onSignalMount={(id) => { onTrackRef.current?.('Signal Shown', { id }); }} diff --git a/packages/compass/src/app/components/home.tsx b/packages/compass/src/app/components/home.tsx index 40133d34a95..44666cc7810 100644 --- a/packages/compass/src/app/components/home.tsx +++ b/packages/compass/src/app/components/home.tsx @@ -210,6 +210,12 @@ export default function ThemedHome( item_label: item.label, }); }} + onDrawerSectionOpen={(drawerSectionId) => { + track('Drawer Section Opened', { sectionId: drawerSectionId }); + }} + onDrawerSectionHide={(drawerSectionId) => { + track('Drawer Section Closed', { sectionId: drawerSectionId }); + }} utmSource="compass" utmMedium="product" onSignalMount={(id) => { From 34d816c2e9bf19679e535978e95b6fbeedf509a0 Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Thu, 25 Sep 2025 16:35:22 +0100 Subject: [PATCH 3/4] DrawerPortal unit tests --- .../src/components/drawer-portal.spec.tsx | 82 ++++++++++++++++++- 1 file changed, 78 insertions(+), 4 deletions(-) diff --git a/packages/compass-components/src/components/drawer-portal.spec.tsx b/packages/compass-components/src/components/drawer-portal.spec.tsx index 2bce21401b5..d984c1af9cd 100644 --- a/packages/compass-components/src/components/drawer-portal.spec.tsx +++ b/packages/compass-components/src/components/drawer-portal.spec.tsx @@ -13,6 +13,7 @@ import { useDrawerActions, } from './drawer-portal'; import { expect } from 'chai'; +import sinon from 'sinon'; describe('DrawerSection', function () { it('renders DrawerSection in the portal and updates the content when it updates', async function () { @@ -60,8 +61,14 @@ describe('DrawerSection', function () { const icons = ['ArrowDown', 'CaretDown', 'ChevronDown'] as const; it('switches drawer content when selecting a different section in the toolbar', async function () { + const onDrawerSectionOpenSpy = sinon.spy(); + const onDrawerSectionHideSpy = sinon.spy(); + render( - + {[1, 2, 3].map((n, idx) => { return ( @@ -85,33 +92,62 @@ describe('DrawerSection', function () { expect(screen.getByText('This is section 1')).to.be.visible; }); + expect(onDrawerSectionOpenSpy).to.have.been.calledOnceWith('section-1'); + onDrawerSectionOpenSpy.resetHistory(); + userEvent.click(screen.getByRole('button', { name: 'Section 2' })); await waitFor(() => { expect(screen.getByText('This is section 2')).to.be.visible; }); + expect(onDrawerSectionHideSpy).to.have.been.calledOnceWith('section-1'); + expect(onDrawerSectionOpenSpy).to.have.been.calledOnceWith('section-2'); + onDrawerSectionOpenSpy.resetHistory(); + onDrawerSectionHideSpy.resetHistory(); + userEvent.click(screen.getByRole('button', { name: 'Section 3' })); await waitFor(() => { expect(screen.getByText('This is section 3')).to.be.visible; }); + expect(onDrawerSectionHideSpy).to.have.been.calledOnceWith('section-2'); + expect(onDrawerSectionOpenSpy).to.have.been.calledOnceWith('section-3'); + onDrawerSectionOpenSpy.resetHistory(); + onDrawerSectionHideSpy.resetHistory(); + userEvent.click(screen.getByRole('button', { name: 'Section 1' })); await waitFor(() => { expect(screen.getByText('This is section 1')).to.be.visible; }); + expect(onDrawerSectionHideSpy).to.have.been.calledOnceWith('section-3'); + expect(onDrawerSectionOpenSpy).to.have.been.calledOnceWith('section-1'); + onDrawerSectionOpenSpy.resetHistory(); + onDrawerSectionHideSpy.resetHistory(); + userEvent.click(screen.getByRole('button', { name: 'Close drawer' })); await waitFor(() => { expect(screen.queryByText('This is section 1')).not.to.exist; expect(screen.queryByText('This is section 2')).not.to.exist; expect(screen.queryByText('This is section 3')).not.to.exist; }); + + expect(onDrawerSectionHideSpy).to.have.been.calledOnceWith('section-1'); + expect(onDrawerSectionOpenSpy).to.not.have.been.called; + onDrawerSectionOpenSpy.resetHistory(); + onDrawerSectionHideSpy.resetHistory(); }); it('closes drawer when opened section is removed from toolbar data', async function () { + const onDrawerSectionOpenSpy = sinon.spy(); + const onDrawerSectionHideSpy = sinon.spy(); + // Render two sections, auto-open first one const { rerender } = render( - + + { const { isDrawerOpen } = useDrawerState(); const { openDrawer, closeDrawer } = useDrawerActions(); @@ -188,7 +242,10 @@ describe('DrawerSection', function () { ); }; render( - + { @@ -221,12 +283,24 @@ describe('DrawerSection', function () { expect(screen.getByText('This is the controlled section')).to.be.visible; }); + expect(onDrawerSectionHideSpy).to.not.have.been.called; + expect(onDrawerSectionOpenSpy).to.have.been.calledOnceWith( + 'controlled-section' + ); + onDrawerSectionOpenSpy.resetHistory(); + onDrawerSectionHideSpy.resetHistory(); + // Close the drawer userEvent.click(screen.getByRole('button', { name: 'Hook Close drawer' })); await waitFor(() => { expect(screen.getByTestId('drawer-state')).to.have.text('closed'); expect(screen.queryByText('This is the controlled section')).not.to.exist; }); + + expect(onDrawerSectionHideSpy).to.have.been.calledOnceWith( + 'controlled-section' + ); + expect(onDrawerSectionOpenSpy).to.not.have.been.called; }); it('renders guide cue when passed in props', async function () { From b7e2b8131f1bb8fcaa4a3397ccfd0103dbfada9b Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Thu, 25 Sep 2025 16:38:16 +0100 Subject: [PATCH 4/4] redundant checks --- .../src/components/drawer-portal.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/compass-components/src/components/drawer-portal.tsx b/packages/compass-components/src/components/drawer-portal.tsx index 29f560b1d6a..b6738f0b240 100644 --- a/packages/compass-components/src/components/drawer-portal.tsx +++ b/packages/compass-components/src/components/drawer-portal.tsx @@ -157,17 +157,11 @@ export const DrawerContentProvider: React.FunctionComponent<{ return; } - if ( - drawerCurrentTab && - drawerCurrentTab !== prevDrawerCurrentTabRef.current - ) { + if (drawerCurrentTab) { onDrawerSectionOpen?.(drawerCurrentTab); } - if ( - prevDrawerCurrentTabRef.current && - drawerCurrentTab !== prevDrawerCurrentTabRef.current - ) { + if (prevDrawerCurrentTabRef.current) { onDrawerSectionHide?.(prevDrawerCurrentTabRef.current); }