diff --git a/.changeset/tender-monkeys-drop.md b/.changeset/tender-monkeys-drop.md new file mode 100644 index 00000000..9692bb8a --- /dev/null +++ b/.changeset/tender-monkeys-drop.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +Fix bug when accessing session replay panel from search page diff --git a/packages/app/src/SessionEventList.tsx b/packages/app/src/SessionEventList.tsx index 5accc571..7134f44a 100644 --- a/packages/app/src/SessionEventList.tsx +++ b/packages/app/src/SessionEventList.tsx @@ -62,6 +62,7 @@ const EventRow = React.forwardRef( return (
-
+
diff --git a/packages/app/src/SessionSubpanel.tsx b/packages/app/src/SessionSubpanel.tsx index f5a302f1..2032d4eb 100644 --- a/packages/app/src/SessionSubpanel.tsx +++ b/packages/app/src/SessionSubpanel.tsx @@ -35,6 +35,7 @@ import { import DBRowSidePanel from '@/components/DBRowSidePanel'; import { RowWhereResult, WithClause } from '@/hooks/useRowWhere'; +import { useZIndex, ZIndexContext } from '@/zIndex'; import SearchWhereInput from './components/SearchInput/SearchWhereInput'; import useFieldExpressionGenerator from './hooks/useFieldExpressionGenerator'; @@ -267,6 +268,8 @@ export default function SessionSubpanel({ whereLanguage?: SearchConditionLanguage; onLanguageChange?: (lang: 'sql' | 'lucene') => void; }) { + const contextZIndex = useZIndex(); + const [rowId, setRowId] = useState(undefined); const [aliasWith, setAliasWith] = useState([]); @@ -466,15 +469,18 @@ export default function SessionSubpanel({
{rowId != null && traceSource && ( - { - setDrawerOpen(false); - setRowId(undefined); - }} - /> + + { + setDrawerOpen(false); + setRowId(undefined); + }} + /> + )}
diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index adf027db..6119936c 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -506,9 +506,11 @@ const DBRowSidePanel = ({
)} > -
+
{ if (!subDrawerOpen) { _onClose(); diff --git a/packages/app/tests/e2e/features/sessions.spec.ts b/packages/app/tests/e2e/features/sessions.spec.ts index c3aba28a..aec7b09f 100644 --- a/packages/app/tests/e2e/features/sessions.spec.ts +++ b/packages/app/tests/e2e/features/sessions.spec.ts @@ -39,4 +39,81 @@ test.describe('Client Sessions Functionality', { tag: ['@sessions'] }, () => { await sessionsPage.openFirstSession(); }); }); + + test( + 'clicking a session event opens the event detail panel with tabs, not another session replay', + { tag: ['@full-stack'] }, + async ({ page }) => { + await test.step('Navigate and open a session (with sidePanelTab=replay pre-set in URL to simulate search-page flow)', async () => { + // Pre-set sidePanelTab=replay in the URL to simulate navigating from a search page + // row detail panel that had the Session Replay tab open. Without isNestedPanel=true, + // the inner DBRowSidePanel would inherit this URL param and open to the Replay tab again. + await page.goto('/search'); + await sessionsPage.goto(); + await sessionsPage.selectDataSource(); + await expect(sessionsPage.getFirstSessionCard()).toBeVisible(); + // Inject sidePanelTab=replay into the URL before opening the session + const currentUrl = page.url(); + await page.goto( + currentUrl.includes('?') + ? `${currentUrl}&sidePanelTab=replay` + : `${currentUrl}?sidePanelTab=replay`, + ); + await expect(sessionsPage.getFirstSessionCard()).toBeVisible(); + await sessionsPage.openFirstSession(); + }); + + await test.step('Wait for session replay drawer and event rows to load', async () => { + await expect(sessionsPage.sessionSidePanel).toBeVisible(); + // Wait for the session event list to populate (routeChange/console.error events are seeded) + await expect(sessionsPage.getSessionEventRows().first()).toBeVisible({ + timeout: 15000, + }); + }); + + await test.step('Click a session event row', async () => { + await sessionsPage.clickFirstSessionEvent(); + }); + + await test.step('Event detail panel opens alongside the session replay — not replacing it', async () => { + // The row-side-panel must be visible (event detail drawer opened on top of session replay) + await expect(sessionsPage.rowSidePanel).toBeVisible(); + + // The original session replay panel must still be open (not replaced/closed) + await expect(sessionsPage.sessionSidePanel).toBeVisible(); + + // Only one session-side-panel must exist (not a second replay opened inside the detail panel) + await expect(page.getByTestId('session-side-panel')).toHaveCount(1); + + // The row-side-panel must show the event detail TabBar (Overview, Trace, etc.) + // This guards against the regression where the inner panel re-opened session replay + // instead of showing event details (which has no TabBar, just the replay player) + await expect( + sessionsPage.rowSidePanel.getByTestId('side-panel-tabs'), + ).toBeVisible(); + + // The inner panel must NOT be showing the Session Replay tab content. + // Without isNestedPanel=true (broken), the inner DBRowSidePanel reads sidePanelTab=replay + // from the URL (injected above) and renders the Session Replay tab content (side-panel-tab-replay). + // With isNestedPanel=true (fixed), the inner panel uses local state and ignores the URL, + // opening to its default tab (Trace/Overview) instead. + await expect( + sessionsPage.rowSidePanel.getByTestId('side-panel-tab-replay'), + ).toHaveCount(0); + }); + + await test.step('Clicking the overlay closes the event detail panel but keeps the session replay open', async () => { + // Without the fix, withOverlay={!isNestedPanel} removed the overlay on nested panels, + // so there was nothing to click to close the panel (it had to be ESC only). + // With the fix (withOverlay always true), clicking the Mantine overlay dismisses the inner panel. + await sessionsPage.clickTopmostDrawerOverlay(); + + // The event detail panel must close + await expect(sessionsPage.rowSidePanel).toBeHidden(); + + // The session replay drawer must still be open + await expect(sessionsPage.sessionSidePanel).toBeVisible(); + }); + }, + ); }); diff --git a/packages/app/tests/e2e/page-objects/SessionsPage.ts b/packages/app/tests/e2e/page-objects/SessionsPage.ts index 33d7d7e9..274f563a 100644 --- a/packages/app/tests/e2e/page-objects/SessionsPage.ts +++ b/packages/app/tests/e2e/page-objects/SessionsPage.ts @@ -68,6 +68,46 @@ export class SessionsPage { await this.getFirstSessionCard().click(); } + /** + * Get the session side panel (the replay drawer) + */ + get sessionSidePanel() { + return this.page.getByTestId('session-side-panel'); + } + + /** + * Get all session event rows inside the replay drawer + */ + getSessionEventRows() { + return this.page.locator('[data-testid^="session-event-row-"]'); + } + + /** + * Click the first session event row to open its detail panel + */ + async clickFirstSessionEvent() { + await this.getSessionEventRows().first().click(); + } + + /** + * Get the row side panel (event detail drawer opened from within session replay) + */ + get rowSidePanel() { + return this.page.getByTestId('row-side-panel'); + } + + /** + * Click the Mantine overlay of the topmost open drawer to close it. + * Mantine renders one overlay per open Drawer. The last one belongs to + * the innermost (topmost) drawer. + */ + async clickTopmostDrawerOverlay() { + // Mantine overlays are siblings of the drawer content inside the portal root. + // Use the last one since the inner panel's overlay is rendered on top. + const overlay = this.page.locator('.mantine-Drawer-overlay').last(); + await overlay.click({ position: { x: 10, y: 10 } }); + } + // Getters for assertions get form() { diff --git a/packages/app/tests/e2e/seed-clickhouse.ts b/packages/app/tests/e2e/seed-clickhouse.ts index ee90a4db..33885611 100644 --- a/packages/app/tests/e2e/seed-clickhouse.ts +++ b/packages/app/tests/e2e/seed-clickhouse.ts @@ -321,6 +321,29 @@ function generateSessionTraces( `('${timestampNs}', '${traceId}', '${spanId}', '', '', '${spanName}', 'SPAN_KIND_INTERNAL', 'browser', {'rum.sessionId':'${sessionId}','service.name':'browser'}, '', '', {'component':'${component}','page.url':'https://example.com/dashboard','teamId':'${teamId}','teamName':'${teamName}','userEmail':'${userEmail}','userName':'${userName}'}, 0, '${statusCode}', '', [], [], [], [], [], [], [])`, ); } + + // Add visible events for the session event list: + // - A routeChange (navigation) event — shown in both Highlighted and All Events tabs + // - A console.error event — shown in both tabs + const sessionUserIndex = i % 5; + const sessionUserEmail = `test${sessionUserIndex}@example.com`; + const sessionUserName = `Test User ${sessionUserIndex}`; + const sessionTeamId = 'test-team-id'; + const sessionTeamName = 'Test Team'; + + const navTimestampNs = (baseTime - 1000) * 1000000; + const navTraceId = `session-nav-${i}`; + const navSpanId = `session-nav-span-${i}`; + rows.push( + `('${navTimestampNs}', '${navTraceId}', '${navSpanId}', '', '', 'routeChange', 'SPAN_KIND_INTERNAL', 'browser', {'rum.sessionId':'${sessionId}','service.name':'browser'}, '', '', {'component':'navigation','location.href':'https://example.com/dashboard','teamId':'${sessionTeamId}','teamName':'${sessionTeamName}','userEmail':'${sessionUserEmail}','userName':'${sessionUserName}'}, 0, 'STATUS_CODE_OK', '', [], [], [], [], [], [], [])`, + ); + + const errTimestampNs = (baseTime - 2000) * 1000000; + const errTraceId = `session-err-${i}`; + const errSpanId = `session-err-span-${i}`; + rows.push( + `('${errTimestampNs}', '${errTraceId}', '${errSpanId}', '', '', 'console.error', 'SPAN_KIND_INTERNAL', 'browser', {'rum.sessionId':'${sessionId}','service.name':'browser'}, '', '', {'component':'error','message':'E2E test error ${i}','teamId':'${sessionTeamId}','teamName':'${sessionTeamName}','userEmail':'${sessionUserEmail}','userName':'${sessionUserName}'}, 0, 'STATUS_CODE_ERROR', '', [], [], [], [], [], [], [])`, + ); } return rows.join(',\n');