From 2f264163119e106fc31a1c3768561285ef3ae79f Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Wed, 5 Aug 2026 12:18:09 +0100 Subject: [PATCH 1/9] feat(app): make View Trace action more prominent with a one-time nudge The log side panel's "View Trace" action was easy to miss: it rendered as subtle inline text blending into the dimmed metadata row. Make it an outlined (secondary) button using the trace source icon, larger and right-aligned so it reads as an action. Add a one-time dismissible popover pointing at the button the first time a user opens a log that has a trace. It persists dismissal under its own localStorage key (hdx-view-trace-callout-dismissed), stays pinned until acknowledged (Got it or clicking View Trace), and only transiently hides on Escape so a stray click or drawer resize can't burn the message before it is read. Also add a data-source-icons skill documenting the canonical icon per source kind so trace/log/session/metric icons stay consistent. Co-authored-by: Cursor --- .changeset/prominent-view-trace-action.md | 10 ++ .claude/skills/data-source-icons/SKILL.md | 55 +++++++++ .../app/src/components/DBRowSidePanel.tsx | 108 +++++++++++++++--- packages/app/tests/e2e/utils/base-test.ts | 7 ++ 4 files changed, 162 insertions(+), 18 deletions(-) create mode 100644 .changeset/prominent-view-trace-action.md create mode 100644 .claude/skills/data-source-icons/SKILL.md diff --git a/.changeset/prominent-view-trace-action.md b/.changeset/prominent-view-trace-action.md new file mode 100644 index 0000000000..1478e5e9e3 --- /dev/null +++ b/.changeset/prominent-view-trace-action.md @@ -0,0 +1,10 @@ +--- +'@hyperdx/app': patch +--- + +Make the log side panel "View Trace" action more noticeable: it now uses an +outlined (secondary) button with the trace source icon, larger compact size, and +is right-aligned so it stands out from the dimmed metadata row instead of +blending in as subtle inline text. A one-time dismissible popover points users to +the button the first time they open a log that has a trace; once dismissed it +never shows again. diff --git a/.claude/skills/data-source-icons/SKILL.md b/.claude/skills/data-source-icons/SKILL.md new file mode 100644 index 0000000000..608121a3b0 --- /dev/null +++ b/.claude/skills/data-source-icons/SKILL.md @@ -0,0 +1,55 @@ +--- +name: data-source-icons +description: Use the correct Tabler icon for each HyperDX data source kind (Log, Trace, Session, Metric, PromQL). Use whenever adding or changing an icon that represents a source kind or a signal type — source selectors, side panels, cross-source actions like "View Trace", tabs, badges, breadcrumbs, or anywhere a log/trace/session/metric is depicted. +--- + +# Data source icons + +There is **one canonical icon per source kind**. The source of truth is +`SOURCE_KIND_ICONS` in +`packages/app/src/components/sourceSelectUtils.tsx`. Never invent a different +icon for a source kind (e.g. don't use `IconTimeline` for a trace) — always +match the table below so icons stay consistent across the app. + +## Canonical mapping + +| Source kind (`SourceKind`) | Tabler icon | Meaning | +| -------------------------- | ---------------- | ----------------------------- | +| `Log` | `IconLogs` | Logs | +| `Trace` | `IconConnection` | Traces / spans | +| `Session` | `IconDeviceLaptop` | Session replay / client sessions | +| `Metric` | `IconChartLine` | Metrics | +| `Promql` | `IconChartLine` | PromQL metrics (same as Metric) | + +All imported from `@tabler/icons-react`. + +## Rules + +1. **Prefer the shared map.** When you need a source-kind icon in a context that + already has (or can accept) a `SourceKind`, use `SOURCE_KIND_ICONS[kind]` + from `sourceSelectUtils.tsx` rather than hardcoding an icon component. +2. **If you must hardcode** (e.g. a fixed "View Trace" action that always points + at a trace), import the exact icon from the table above — for a trace that is + `IconConnection`, not `IconTimeline`/`IconRoute`/etc. +3. **Sizing**: the shared map uses `size={16}`. Match the surrounding UI; small + inline buttons/badges commonly use `14`. +4. **Adding a new source kind**: update `SOURCE_KIND_ICONS` first, then update + this table so the two never drift. + +## Examples + +Cross-source "View Trace" action (fixed target → hardcode the trace icon): + +```tsx +import { IconConnection } from '@tabler/icons-react'; + +; +``` + +Dynamic, kind-driven icon (prefer the shared map): + +```tsx +import { SOURCE_KIND_ICONS } from '@/components/sourceSelectUtils'; + +{SOURCE_KIND_ICONS[source.kind]}; +``` diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index 4d91dae2d5..ba7f46048c 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -30,11 +30,19 @@ import { Drawer, Flex, Group, + Popover, Stack, Text, Tooltip, } from '@mantine/core'; -import { IconCopy, IconKeyboard, IconShare, IconX } from '@tabler/icons-react'; +import { + IconArrowRight, + IconConnection, + IconCopy, + IconKeyboard, + IconShare, + IconX, +} from '@tabler/icons-react'; import { useCloseOnClickOutside } from '@/hooks/useCloseOnClickOutside'; import useResizable from '@/hooks/useResizable'; @@ -49,7 +57,7 @@ import { getEventBody, useSource } from '@/source'; import TabBar from '@/TabBar'; import { SearchConfig } from '@/types'; import { FormatTime } from '@/useFormatTime'; -import { formatDistanceToNowStrictShort } from '@/utils'; +import { formatDistanceToNowStrictShort, useLocalStorage } from '@/utils'; import { getHighlightedAttributesFromData } from '@/utils/highlightedAttributes'; import { useZIndex, ZIndexContext } from '@/zIndex'; @@ -413,6 +421,20 @@ export const DBRowSidePanelInner = ({ Record >({}); + // One-time nudge pointing at the (now more prominent) "View trace" button. + // Two levels of closing: a transient close (click-away / Escape) just hides it + // for now so it can reappear next time — an accidental click shouldn't burn the + // message before it's read — while an explicit acknowledgement (Got it, or + // clicking View Trace) persists the dismissal so it never shows again. The + // persisted flag lives under its own localStorage key, not in user settings. + const [viewTraceCalloutDismissed, setViewTraceCalloutDismissed] = + useLocalStorage('hdx-view-trace-callout-dismissed', false); + const [viewTraceCalloutClosed, setViewTraceCalloutClosed] = useState(false); + const dismissViewTraceCallout = useCallback(() => { + setViewTraceCalloutClosed(true); + setViewTraceCalloutDismissed(true); + }, [setViewTraceCalloutDismissed]); + useEffect(() => { if ( activeRowId != null && @@ -865,25 +887,75 @@ export const DBRowSidePanelInner = ({ )} {showLogTraceActions && ( - + + + + + + + View trace is easier to find now 🎉 + + + Jump straight to this log's full trace — one click. + + + + + )} Date: Wed, 5 Aug 2026 12:47:37 +0100 Subject: [PATCH 2/9] fix(app): stop the View Trace callout from fighting the panel Esc hotkey Address review feedback on #2815: - P0/P1: the callout's closeOnEscape collided with the pre-existing document-level useHotkeys(['esc'], handlePanelBack), so Escape closed or rewound the drawer instead of acting cleanly on the callout. The callout no longer intercepts Escape (closeOnEscape={false}); Escape keeps its normal panel behavior. - P1: the transient viewTraceCalloutClosed state was never reset across row changes in the same (unkeyed) panel instance, so one Escape suppressed the callout for every later eligible log. Removed the transient close entirely. The callout is now dismissed only by explicit acknowledgement (Got it, or clicking View Trace), which persists under its own localStorage key; otherwise it stays pinned and simply reappears the next time the panel opens on an eligible log, which naturally resets per open. Co-authored-by: Cursor --- .../app/src/components/DBRowSidePanel.tsx | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index ba7f46048c..d693cfbc50 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -422,16 +422,16 @@ export const DBRowSidePanelInner = ({ >({}); // One-time nudge pointing at the (now more prominent) "View trace" button. - // Two levels of closing: a transient close (click-away / Escape) just hides it - // for now so it can reappear next time — an accidental click shouldn't burn the - // message before it's read — while an explicit acknowledgement (Got it, or - // clicking View Trace) persists the dismissal so it never shows again. The - // persisted flag lives under its own localStorage key, not in user settings. + // Dismissed only by an explicit acknowledgement (Got it, or clicking View + // Trace), which persists under its own localStorage key (not user settings). + // Otherwise it stays pinned: stray clicks, drawer resizes, and Escape do not + // dismiss it. Escape keeps its normal panel behavior (handled by the panel-level + // hotkey); the callout deliberately does not intercept it. Because nothing + // transient is stored, the nudge simply reappears the next time the panel opens + // on an eligible log until it is acknowledged. const [viewTraceCalloutDismissed, setViewTraceCalloutDismissed] = useLocalStorage('hdx-view-trace-callout-dismissed', false); - const [viewTraceCalloutClosed, setViewTraceCalloutClosed] = useState(false); const dismissViewTraceCallout = useCallback(() => { - setViewTraceCalloutClosed(true); setViewTraceCalloutDismissed(true); }, [setViewTraceCalloutDismissed]); @@ -894,23 +894,16 @@ export const DBRowSidePanelInner = ({ shadow="md" trapFocus={false} // Stay pinned to the button until the user acknowledges it (Got it - // / View Trace) or presses Escape. Not closed by stray clicks or - // resizing the drawer, which are not intents to dismiss. + // / View Trace). Deliberately does not close on stray clicks, drawer + // resizes, or Escape — Escape stays owned by the panel-level hotkey + // so the callout never fights it for the keypress. closeOnClickOutside={false} - closeOnEscape + closeOnEscape={false} opened={ !!traceSourceData && !!traceSpanRowId && - !viewTraceCalloutDismissed && - !viewTraceCalloutClosed + !viewTraceCalloutDismissed } - onChange={opened => { - // Click-away / Escape only hides it for now (transient) so an - // unread callout gets another chance; it is not persisted. - if (!opened) { - setViewTraceCalloutClosed(true); - } - }} > - - - - - View trace is easier to find now 🎉 - - - Jump straight to this log's full trace — one click. - - - - - + { + if (traceSourceData && traceSpanRowId) { + handleSourceStackPush({ + sourceId: traceSourceData.id, + rowId: traceSpanRowId, + label: mainContent || 'Log', + sourceKind: traceSourceData.kind as SourceKind, + aliasWith: [], + }); + } + }} + /> )} void; +}; + +/** + * The prominent "View trace" action shown in a log side panel, wrapped in a + * one-time nudge popover. + * + * The callout is dismissed only by an explicit acknowledgement — clicking + * "Got it" or the button itself — which persists under its own localStorage key + * (not user settings). It deliberately does not close on stray clicks, drawer + * resizes, or Escape: Escape stays owned by the panel-level hotkey so the + * callout never fights it for the keypress. Because nothing transient is + * stored, the nudge simply reappears the next time the panel opens on an + * eligible log until it is acknowledged. + */ +export function ViewTraceCalloutButton({ + disabled, + onView, +}: ViewTraceCalloutButtonProps) { + const [dismissed, setDismissed] = useLocalStorage( + VIEW_TRACE_CALLOUT_DISMISSED_KEY, + false, + ); + const dismiss = useCallback(() => setDismissed(true), [setDismissed]); + + return ( + + + + + + + + View trace is easier to find now 🎉 + + + Jump straight to this log's full trace — one click. + + + + + + ); +} diff --git a/packages/app/src/components/__tests__/ViewTraceCalloutButton.test.tsx b/packages/app/src/components/__tests__/ViewTraceCalloutButton.test.tsx new file mode 100644 index 0000000000..8fe6ddec53 --- /dev/null +++ b/packages/app/src/components/__tests__/ViewTraceCalloutButton.test.tsx @@ -0,0 +1,70 @@ +import { fireEvent, screen } from '@testing-library/react'; + +import { VIEW_TRACE_CALLOUT_DISMISSED_KEY } from '@/components/viewTraceCallout'; +import { ViewTraceCalloutButton } from '@/components/ViewTraceCalloutButton'; + +describe('ViewTraceCalloutButton', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('shows the one-time callout when enabled and not yet dismissed', () => { + renderWithMantine( + , + ); + + expect(screen.getByTestId('side-panel-view-trace')).toBeEnabled(); + expect(screen.getByTestId('view-trace-callout')).toBeInTheDocument(); + expect(screen.getByText('Got it')).toBeInTheDocument(); + }); + + it('does not open the callout while the trace is unresolved (disabled)', () => { + renderWithMantine(); + + expect(screen.getByTestId('side-panel-view-trace')).toBeDisabled(); + expect(screen.queryByTestId('view-trace-callout')).not.toBeInTheDocument(); + }); + + it('does not show the callout once it has been dismissed previously', () => { + window.localStorage.setItem( + VIEW_TRACE_CALLOUT_DISMISSED_KEY, + JSON.stringify(true), + ); + + renderWithMantine( + , + ); + + // Button still available, but the nudge no longer appears. + expect(screen.getByTestId('side-panel-view-trace')).toBeInTheDocument(); + expect(screen.queryByTestId('view-trace-callout')).not.toBeInTheDocument(); + }); + + it('persists dismissal and navigates when the View Trace button is clicked', () => { + const onView = jest.fn(); + renderWithMantine( + , + ); + + fireEvent.click(screen.getByTestId('side-panel-view-trace')); + + expect(onView).toHaveBeenCalledTimes(1); + expect(window.localStorage.getItem(VIEW_TRACE_CALLOUT_DISMISSED_KEY)).toBe( + JSON.stringify(true), + ); + }); + + it('persists dismissal without navigating when "Got it" is clicked', () => { + const onView = jest.fn(); + renderWithMantine( + , + ); + + fireEvent.click(screen.getByText('Got it')); + + expect(onView).not.toHaveBeenCalled(); + expect(window.localStorage.getItem(VIEW_TRACE_CALLOUT_DISMISSED_KEY)).toBe( + JSON.stringify(true), + ); + }); +}); diff --git a/packages/app/src/components/viewTraceCallout.ts b/packages/app/src/components/viewTraceCallout.ts new file mode 100644 index 0000000000..45cc459105 --- /dev/null +++ b/packages/app/src/components/viewTraceCallout.ts @@ -0,0 +1,9 @@ +/** + * localStorage key for the one-time "View trace" button callout dismissal. + * + * Kept in this dependency-free module so it can be shared by the React + * component and the Playwright fixture (which seeds it) without either copy + * drifting from the other. + */ +export const VIEW_TRACE_CALLOUT_DISMISSED_KEY = + 'hdx-view-trace-callout-dismissed'; diff --git a/packages/app/tests/e2e/utils/base-test.ts b/packages/app/tests/e2e/utils/base-test.ts index fcfd454bc7..367e2bd763 100644 --- a/packages/app/tests/e2e/utils/base-test.ts +++ b/packages/app/tests/e2e/utils/base-test.ts @@ -2,6 +2,8 @@ import fs from 'fs'; import path from 'path'; import { expect, test as base } from '@playwright/test'; +import { VIEW_TRACE_CALLOUT_DISMISSED_KEY } from '../../../src/components/viewTraceCallout'; + // Single source of truth: e2e-fixtures.json (connections/sources). API gets them via run-api-with-fixtures.js. const E2E_FIXTURES_PATH = path.join(__dirname, '../fixtures/e2e-fixtures.json'); function loadE2EFixtures(): { connections: unknown[]; sources: unknown[] } { @@ -28,7 +30,7 @@ export const test = base.extend({ // e2e-fixtures.json so local mode uses the same data as full-stack. await page.addInitScript( (arg: unknown[]) => { - const [connections, sources] = arg; + const [connections, sources, calloutDismissedKey] = arg; window.localStorage.setItem('TanstackQueryDevtools.open', 'false'); window.sessionStorage.setItem( 'connections', @@ -42,11 +44,15 @@ export const test = base.extend({ // popover never interferes with side-panel interactions. useLocalStorage // JSON-encodes values, so store the boolean as JSON. window.localStorage.setItem( - 'hdx-view-trace-callout-dismissed', + calloutDismissedKey as string, JSON.stringify(true), ); }, - [e2eFixtures.connections, e2eFixtures.sources], + [ + e2eFixtures.connections, + e2eFixtures.sources, + VIEW_TRACE_CALLOUT_DISMISSED_KEY, + ], ); await fn(page); }, From e82ea759014a50346bf16bbf34fc6a8bae355609 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Wed, 5 Aug 2026 13:06:52 +0100 Subject: [PATCH 4/9] fix(app): use placement-neutral copy for the View Trace callout The callout shows to anyone without the dismissal flag, including brand-new users, fresh profiles, and incognito sessions who never saw a prior placement. "easier to find now" framed it as a change announcement for those users, so switch to neutral copy that just describes what the button does. Co-authored-by: Cursor --- packages/app/src/components/ViewTraceCalloutButton.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/ViewTraceCalloutButton.tsx b/packages/app/src/components/ViewTraceCalloutButton.tsx index e317c7d2d8..12db3882d4 100644 --- a/packages/app/src/components/ViewTraceCalloutButton.tsx +++ b/packages/app/src/components/ViewTraceCalloutButton.tsx @@ -70,10 +70,10 @@ export function ViewTraceCalloutButton({ > - View trace is easier to find now 🎉 + Jump to this log's full trace - Jump straight to this log's full trace — one click. + Open the correlated trace in one click.