From 6d596843566f14355b26bfd5d7ffab090424aff9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 14 Sep 2026 01:05:19 +0000 Subject: [PATCH 1/7] feat(app): expand result rows inline on row click Clicking a row body opened the side panel, leaving the 16px chevron as the only way to expand inline - a poor hit target during an incident, and the opposite of what comparable tools do. Row clicks now toggle inline expansion. The side panel moves to a row hover button, still stays one click away from an expanded row, and keeps receiving row clicks while it is open so browsing row to row is unchanged. Preferences -> Search results -> Row click restores the old behavior. Co-authored-by: Mike Shi --- .changeset/witty-mangos-listen.md | 8 + packages/app/src/UserPreferencesModal.tsx | 29 ++- packages/app/src/components/DBRowTable.tsx | 41 +++- .../DBTable/DBRowTableIconButton.tsx | 1 + .../DBTable/DBRowTableRowButtons.tsx | 19 +- .../app/src/components/ExpandableRowTable.tsx | 4 +- .../components/__tests__/DBRowTable.test.tsx | 196 ++++++++++++++++++ packages/app/src/useUserPreferences.tsx | 11 + .../tests/e2e/components/TableComponent.ts | 39 +++- .../features/correlated-metric-source.spec.ts | 5 +- .../search/json-add-to-filter.spec.ts | 2 +- .../search/json-viewer-whitespace.spec.ts | 2 +- .../tests/e2e/features/search/search.spec.ts | 34 ++- .../search/select-star-error-state.spec.ts | 2 +- .../search/side-panel-navigation.spec.ts | 2 +- .../app/tests/e2e/features/span-links.spec.ts | 2 +- .../e2e/features/traces-workflow.spec.ts | 3 +- .../app/tests/e2e/page-objects/SearchPage.ts | 2 +- 18 files changed, 379 insertions(+), 23 deletions(-) create mode 100644 .changeset/witty-mangos-listen.md diff --git a/.changeset/witty-mangos-listen.md b/.changeset/witty-mangos-listen.md new file mode 100644 index 0000000000..10e8218f06 --- /dev/null +++ b/.changeset/witty-mangos-listen.md @@ -0,0 +1,8 @@ +--- +'@hyperdx/app': minor +--- + +Clicking a search result row now expands it inline instead of opening the side +panel. The side panel moved to a hover button on the row (and is still one click +away from an expanded row), row clicks keep driving it once it is open, and +Preferences → Search results → Row click restores the old behavior. diff --git a/packages/app/src/UserPreferencesModal.tsx b/packages/app/src/UserPreferencesModal.tsx index 578e784bbb..1a5a371bc1 100644 --- a/packages/app/src/UserPreferencesModal.tsx +++ b/packages/app/src/UserPreferencesModal.tsx @@ -16,7 +16,11 @@ import { IconFlask } from '@tabler/icons-react'; import { OPTIONS_FONTS } from './config/fonts'; import { useAppTheme } from './theme/ThemeProvider'; import { isValidThemeName, themes } from './theme'; -import { UserPreferences, useUserPreferences } from './useUserPreferences'; +import { + DEFAULT_ROW_CLICK_ACTION, + UserPreferences, + useUserPreferences, +} from './useUserPreferences'; const OPTIONS_COLOR_MODE = [ { label: 'System', value: 'system' }, @@ -24,6 +28,11 @@ const OPTIONS_COLOR_MODE = [ { label: 'Dark', value: 'dark' }, ]; +const OPTIONS_ROW_CLICK_ACTION = [ + { label: 'Expand inline', value: 'expand' }, + { label: 'Open side panel', value: 'sidePanel' }, +]; + // Brand theme options (generated from theme registry) const OPTIONS_BRAND_THEMES = Object.values(themes).map(t => ({ label: t.displayName, @@ -108,6 +117,24 @@ export const UserPreferencesModal = ({ /> + + + value && diff --git a/packages/app/src/components/DBRowTable.tsx b/packages/app/src/components/DBRowTable.tsx index d06fb257e0..1b252e9e6b 100644 --- a/packages/app/src/components/DBRowTable.tsx +++ b/packages/app/src/components/DBRowTable.tsx @@ -388,7 +388,7 @@ export const RawLogTable = memo( rows: Record[]; isLoading?: boolean; fetchNextPage?: (options?: FetchNextPageOptions | undefined) => any; - onRowDetailsClick: (row: Record) => void; + onRowDetailsClick?: (row: Record) => void; generateRowId: (row: Record) => RowWhereResult; hasNextPage?: boolean; highlightedLineId?: string; @@ -459,16 +459,21 @@ export const RawLogTable = memo( userPreferences: { isUTC, rowClickAction }, } = useUserPreferences(); + // The ClickHouse dashboard's slow-query list has no side panel to open, so + // inline expansion is the only thing a row click can do there. + const canOpenSidePanel = onRowDetailsClick != null; + // Inline expansion is owned by the expand-button column, so tables that hide // it (surrounding context, patterns) always fall back to the side panel. const prefersInlineExpand = showExpandButton && - (rowClickAction ?? DEFAULT_ROW_CLICK_ACTION) === 'expand'; + (!canOpenSidePanel || + (rowClickAction ?? DEFAULT_ROW_CLICK_ACTION) === 'expand'); // Once the side panel is open it stays the active surface, so clicks keep // moving it from row to row instead of expanding rows behind it. const expandInlineOnRowClick = - prefersInlineExpand && highlightedLineId == null; + prefersInlineExpand && (!canOpenSidePanel || highlightedLineId == null); const [columnSizeStorage, setColumnSizeStorage] = useLocalStorage< Record @@ -1285,7 +1290,7 @@ export const RawLogTable = memo( setWrapLinesEnabled(!wrapLinesEnabled) } onOpenSidePanel={ - prefersInlineExpand + prefersInlineExpand && canOpenSidePanel ? () => _onRowExpandClick(row.original) : undefined } @@ -1934,7 +1939,9 @@ function DBSqlRowTableComponent({ fetchNextPage={fetchNextPage} // onPropertySearchClick={onPropertySearchClick} hasNextPage={hasNextPage} - onRowDetailsClick={_onRowDetailsClick} + // Passed through conditionally so tables with no side panel (the + // ClickHouse dashboard's slow-query list) don't offer to open one. + onRowDetailsClick={onRowDetailsClick ? _onRowDetailsClick : undefined} onScroll={onScroll} generateRowId={getRowWhere} isError={isError} diff --git a/packages/app/src/components/__tests__/DBRowTable.test.tsx b/packages/app/src/components/__tests__/DBRowTable.test.tsx index f8c5f29eef..abc1bde579 100644 --- a/packages/app/src/components/__tests__/DBRowTable.test.tsx +++ b/packages/app/src/components/__tests__/DBRowTable.test.tsx @@ -408,6 +408,23 @@ describe('RawLogTable', () => { expect(onRowDetailsClick).toHaveBeenCalledTimes(1); }); + // The ClickHouse dashboard's slow-query list renders inline details with no + // side panel behind them. + it('expands inline regardless of the preference when there is no side panel', async () => { + renderTable(, 'sidePanel'); + + const rowBody = await screen.findByRole('button', { + name: 'Expand log row', + }); + expect( + screen.queryByRole('button', { name: 'Open in side panel' }), + ).not.toBeInTheDocument(); + + await userEvent.click(rowBody); + + expect(await screen.findByTestId(`expanded-row-${ROW_ID}`)).toBeVisible(); + }); + // With the panel open it stays the active surface, so a row click moves it // rather than expanding rows behind it. it('moves the open side panel on row click instead of expanding', async () => { From eae6338bdd35fcfb6e7d6f5633e15b5b9209738a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 14 Sep 2026 05:27:46 +0000 Subject: [PATCH 3/7] fix(app): tie row-click routing to a side panel that actually exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings, all the same root cause — the code inferred "a side panel is available/open" from signals that do not mean that: - `highlightedLineId` was passed even when the panel was not mounted. Because `rowWhere`/`rowSource` survive a source switch, a stale `rowWhere` left the table highlighting a missing row and suppressing inline expansion, so the first click after switching source opened the panel instead of expanding. Both now derive from the panel element. - `ExpandedLogRow` guarded its maximize button on `{openSidebar && ...}`, the result of a `useCallback` and so always truthy. On the ClickHouse dashboard's slow-query list, which renders no panel, the button only wrote URL params nothing reads. It now takes `canOpenSidePanel`, matching the row hover button. - `clickRow`/`clickFirstRow` lost their last callers and would silently expand inline for a future spec reaching for the side panel. Removed in favour of `clickRowBody`/`openRowSidePanel`. Also covers the preferences Select, whose stored key and option values no test exercised. Co-authored-by: Mike Shi --- .../__tests__/UserPreferencesModal.test.tsx | 64 +++++++++++++++++++ packages/app/src/components/DBRowTable.tsx | 1 + .../components/DBSqlRowTableWithSidebar.tsx | 29 +++++---- .../app/src/components/ExpandableRowTable.tsx | 5 +- .../components/__tests__/DBRowTable.test.tsx | 5 ++ .../tests/e2e/components/TableComponent.ts | 15 ----- 6 files changed, 92 insertions(+), 27 deletions(-) create mode 100644 packages/app/src/__tests__/UserPreferencesModal.test.tsx diff --git a/packages/app/src/__tests__/UserPreferencesModal.test.tsx b/packages/app/src/__tests__/UserPreferencesModal.test.tsx new file mode 100644 index 0000000000..b3685a1a38 --- /dev/null +++ b/packages/app/src/__tests__/UserPreferencesModal.test.tsx @@ -0,0 +1,64 @@ +import { Provider } from 'jotai'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { UserPreferencesModal } from '@/UserPreferencesModal'; +import type { UserPreferences } from '@/useUserPreferences'; + +const STORAGE_KEY = 'hdx-user-preferences'; + +const renderModal = (stored?: Partial) => { + window.localStorage.clear(); + if (stored) { + window.localStorage.setItem( + STORAGE_KEY, + // `colorMode` is what marks stored preferences as already migrated. + JSON.stringify({ colorMode: 'dark', ...stored }), + ); + } + return renderWithMantine( + + {}} /> + , + ); +}; + +const readStoredPreferences = (): UserPreferences => + JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '{}'); + +describe('UserPreferencesModal', () => { + describe('Row click', () => { + it('shows the default action for users with no stored preference', () => { + renderModal(); + + expect(screen.getByTestId('row-click-action-select')).toHaveValue( + 'Expand inline', + ); + }); + + it('reflects a stored preference', () => { + renderModal({ rowClickAction: 'sidePanel' }); + + expect(screen.getByTestId('row-click-action-select')).toHaveValue( + 'Open side panel', + ); + }); + + it('stores the selected action', async () => { + renderModal(); + + await userEvent.click(screen.getByTestId('row-click-action-select')); + await userEvent.click( + await screen.findByRole('option', { + name: 'Open side panel', + hidden: true, + }), + ); + + expect(screen.getByTestId('row-click-action-select')).toHaveValue( + 'Open side panel', + ); + expect(readStoredPreferences().rowClickAction).toBe('sidePanel'); + }); + }); +}); diff --git a/packages/app/src/components/DBRowTable.tsx b/packages/app/src/components/DBRowTable.tsx index 1b252e9e6b..7a31fa0390 100644 --- a/packages/app/src/components/DBRowTable.tsx +++ b/packages/app/src/components/DBRowTable.tsx @@ -1309,6 +1309,7 @@ export const RawLogTable = memo( rowId={rowId} measureElement={rowVirtualizer.measureElement} virtualIndex={virtualRow.index} + canOpenSidePanel={canOpenSidePanel} > {renderRowDetails?.({ id: rowId, diff --git a/packages/app/src/components/DBSqlRowTableWithSidebar.tsx b/packages/app/src/components/DBSqlRowTableWithSidebar.tsx index 342e87e2ff..c683786711 100644 --- a/packages/app/src/components/DBSqlRowTableWithSidebar.tsx +++ b/packages/app/src/components/DBSqlRowTableWithSidebar.tsx @@ -120,23 +120,30 @@ export default function DBSqlRowTableWithSideBar({ [sourceData], ); + // `rowWhere`/`rowSource` survive a source switch, so a stale `rowWhere` can + // outlive the panel it belongs to. Deriving the table's highlight from this + // element keeps it from highlighting a row — and from treating the panel as + // open, which suppresses inline expansion — while no panel is mounted. + const sidePanel = + sourceData && (rowSource === sourceId || !rowSource) ? ( + + ) : null; + return ( - {sourceData && (rowSource === sourceId || !rowSource) && ( - - )} + {sidePanel} void; virtualIndex?: number; + /** False on tables that render no side panel, so the URL params go nowhere. */ + canOpenSidePanel?: boolean; }) => { const openSidebar = useSidebarOpener(); @@ -55,7 +58,7 @@ export const ExpandedLogRow = memo(
- {openSidebar && ( + {canOpenSidePanel && (