Skip to content

Commit de527bf

Browse files
authored
feat(app): make View Trace action more prominent with a one-time nudge (#2815)
## Summary Users reported that the log side panel's **View Trace** action is easy to miss. Since traces moved out of a dedicated tab, the action rendered as low-emphasis inline text (`variant="subtle"`, `compact-xs`) blending into the dimmed metadata row (timestamp, service, Trace ID). This PR makes it noticeable and adds a gentle one-time nudge: - **Prominent button** (`ViewTraceCalloutButton`): outlined (`secondary`) button, larger (`compact-sm`), right-aligned (`ml="auto"`), using the **trace source icon** (`IconConnection`, matching `SOURCE_KIND_ICONS`) plus a trailing arrow. Same `data-testid` / navigation behavior, so E2E helpers are unchanged. - **One-time popover** pointing at the button the first time a user opens a log that has a correlated trace: - Dismissed only by an **explicit acknowledgement** — clicking **Got it** or the **View Trace** button — which persists under its own dedicated localStorage key (shared constant `VIEW_TRACE_CALLOUT_DISMISSED_KEY`), deliberately **not** in `hdx-user-preferences` (that's for real user settings). - Stays pinned otherwise: stray clicks and drawer resizes do not dismiss it, so an accidental interaction can't burn the message before it's read. - Does **not** intercept Escape — Escape keeps its normal side-panel behavior (owned by the panel-level hotkey), so the callout never fights it for the keypress. Nothing transient is stored, so the nudge simply reappears next time the panel opens on an eligible log until acknowledged. - Gated on the trace being resolved (same condition as the button's enabled state) so it never points at a disabled button. - `role="status"` / `aria-live="polite"` so screen readers announce it when it auto-opens. - **`data-source-icons` skill**: documents the canonical Tabler icon per `SourceKind` (log/trace/session/metric/promql) to keep icon usage consistent going forward. - E2E `base-test.ts` seeds the dismissed flag (via the shared key) so the auto-popover never interferes with side-panel specs. ## Why The subtle inline treatment didn't read as an action to users coming from the old Trace tab or the previous "View trace" link. Emphasizing the button (without reintroducing tab/push navigation inconsistency) plus a self-retiring, placement-neutral nudge addresses discoverability with minimal behavioral change. ## Screenshot <img width="1518" height="1054" alt="image" src="https://github.com/user-attachments/assets/c330ce36-e23a-4b14-8868-954456d21fad" /> ## Review feedback addressed - **P0/P1 — Escape ownership**: the callout no longer sets `closeOnEscape`, so it can't collide with the pre-existing `useHotkeys(['esc'], handlePanelBack)`; Escape behaves exactly as before. - **P1 — transient state leak**: removed the transient `viewTraceCalloutClosed` state entirely, so a dismissal can no longer persist across row changes in the same panel instance. - **P2/P3 — structure & coverage**: extracted the inline popover into a dedicated `ViewTraceCalloutButton` component (keeps `DBRowSidePanel` focused, makes the nudge testable); added a unit test suite (opened gate, already-dismissed, and both persist paths); shared the localStorage key via a dependency-free constant imported by both the component and the E2E fixture; added `role`/`aria-live` for screen readers; and used placement-neutral copy so brand-new users don't get a change announcement for a placement they never saw. ## Test plan - [x] `yarn workspace @hyperdx/app run ci:unit` — `DBRowSidePanel.*` + new `ViewTraceCalloutButton` suites pass - [x] `yarn lint:fix` clean; pre-commit hooks (prettier/eslint/knip) pass - [x] E2E suite green on CI (side-panel navigation unaffected; callout seeded off) - [ ] Manual: open a log with a trace → popover appears once trace resolves; Got it / View Trace dismiss permanently (`hdx-view-trace-callout-dismissed: true`); Escape performs normal panel back/close; resizing the drawer does not dismiss - [ ] Manual: a log without trace context shows no button and no popover
1 parent 347f0a6 commit de527bf

8 files changed

Lines changed: 376 additions & 12 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@hyperdx/app': patch
3+
---
4+
5+
Make the log side panel "View Trace" action more noticeable: it now uses an
6+
outlined (secondary) button with the trace source icon, larger compact size, and
7+
is right-aligned so it stands out from the dimmed metadata row instead of
8+
blending in as subtle inline text. The first time a log with a correlated trace
9+
is opened, a one-time popover points users to the button; it is dismissed by an
10+
explicit acknowledgement ("Got it" or clicking View Trace) and then never shows
11+
again (persisted per browser). It deliberately does not intercept Escape, which
12+
keeps its normal side-panel behavior.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
name: data-source-icons
3+
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.
4+
---
5+
6+
# Data source icons
7+
8+
There is **one canonical icon per source kind**. The source of truth is
9+
`SOURCE_KIND_ICONS` in
10+
`packages/app/src/components/sourceSelectUtils.tsx`. Never invent a different
11+
icon for a source kind (e.g. don't use `IconTimeline` for a trace) — always
12+
match the table below so icons stay consistent across the app.
13+
14+
## Canonical mapping
15+
16+
| Source kind (`SourceKind`) | Tabler icon | Meaning |
17+
| -------------------------- | ---------------- | ----------------------------- |
18+
| `Log` | `IconLogs` | Logs |
19+
| `Trace` | `IconConnection` | Traces / spans |
20+
| `Session` | `IconDeviceLaptop` | Session replay / client sessions |
21+
| `Metric` | `IconChartLine` | Metrics |
22+
| `Promql` | `IconChartLine` | PromQL metrics (same as Metric) |
23+
24+
All imported from `@tabler/icons-react`.
25+
26+
## Rules
27+
28+
1. **Prefer the shared map.** When you need a source-kind icon in a context that
29+
already has (or can accept) a `SourceKind`, use `SOURCE_KIND_ICONS[kind]`
30+
from `sourceSelectUtils.tsx` rather than hardcoding an icon component.
31+
2. **If you must hardcode** (e.g. a fixed "View Trace" action that always points
32+
at a trace), import the exact icon from the table above — for a trace that is
33+
`IconConnection`, not `IconTimeline`/`IconRoute`/etc.
34+
3. **Sizing**: the shared map uses `size={16}`. Match the surrounding UI; small
35+
inline buttons/badges commonly use `14`.
36+
4. **Adding a new source kind**: update `SOURCE_KIND_ICONS` first, then update
37+
this table so the two never drift.
38+
39+
## Examples
40+
41+
Cross-source "View Trace" action (fixed target → hardcode the trace icon):
42+
43+
```tsx
44+
import { IconConnection } from '@tabler/icons-react';
45+
46+
<Button leftSection={<IconConnection size={14} />}>View Trace</Button>;
47+
```
48+
49+
Dynamic, kind-driven icon (prefer the shared map):
50+
51+
```tsx
52+
import { SOURCE_KIND_ICONS } from '@/components/sourceSelectUtils';
53+
54+
<span>{SOURCE_KIND_ICONS[source.kind]}</span>;
55+
```

‎knip.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@
2626
},
2727
"packages/common-utils": {
2828
"entry": ["src/**/*.ts", "!src/__tests__/**", "!src/**/*.test.*"],
29-
"project": ["src/**/*.ts"]
29+
"project": ["src/**/*.ts"],
30+
"ignoreDependencies": ["@stryker-mutator/core"],
31+
"ignoreBinaries": ["stryker"]
3032
},
3133
"packages/hdx-eval": {
3234
"project": ["src/**/*.ts"],

‎packages/app/src/components/DBRowSidePanel.tsx‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ import {
8080
import LogLevel from './LogLevel';
8181
import SidePanelBreadcrumbs, { BreadcrumbItem } from './SidePanelBreadcrumbs';
8282
import { SpanLinkData } from './SpanLinksSubpanel';
83+
import { ViewTraceCalloutButton } from './ViewTraceCalloutButton';
8384

8485
import styles from '@/../styles/LogSidePanel.module.scss';
8586

@@ -440,6 +441,17 @@ export const DBRowSidePanelInner = ({
440441
const severityText: string | undefined =
441442
normalizedRow?.['__hdx_severity_text'];
442443

444+
// Owns the View Trace nudge's auto-open latch. Kept here (above the loading
445+
// gate that unmounts the row content on every navigation) so the one-time
446+
// callout doesn't reset and re-open as the user pages between rows. Resets
447+
// when the panel closes and this component unmounts.
448+
const [viewTraceCalloutAutoOpened, setViewTraceCalloutAutoOpened] =
449+
useState(false);
450+
const handleViewTraceCalloutAutoOpen = useCallback(
451+
() => setViewTraceCalloutAutoOpened(true),
452+
[],
453+
);
454+
443455
// Capture the root event body once for the root breadcrumb label.
444456
const [initialMainContent, setInitialMainContent] = useState<
445457
string | undefined
@@ -931,11 +943,11 @@ export const DBRowSidePanelInner = ({
931943
</>
932944
)}
933945
{showLogTraceActions && (
934-
<Button
935-
data-testid="side-panel-view-trace"
936-
variant="subtle"
937-
size="compact-xs"
938-
onClick={() => {
946+
<ViewTraceCalloutButton
947+
disabled={!traceSourceData || !traceSpanRowId}
948+
autoOpened={viewTraceCalloutAutoOpened}
949+
onAutoOpen={handleViewTraceCalloutAutoOpen}
950+
onView={() => {
939951
if (traceSourceData && traceSpanRowId) {
940952
handleSourceStackPush({
941953
sourceId: traceSourceData.id,
@@ -947,10 +959,7 @@ export const DBRowSidePanelInner = ({
947959
});
948960
}
949961
}}
950-
disabled={!traceSourceData || !traceSpanRowId}
951-
>
952-
View Trace →
953-
</Button>
962+
/>
954963
)}
955964
</Group>
956965
<DBRowSidePanelHeader
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { useCallback, useEffect } from 'react';
2+
import { Button, Popover, Stack, Text } from '@mantine/core';
3+
import { IconArrowRight, IconConnection } from '@tabler/icons-react';
4+
5+
import { useLocalStorage } from '@/utils';
6+
7+
import { VIEW_TRACE_CALLOUT_DISMISSED_KEY } from './viewTraceCallout';
8+
9+
type ViewTraceCalloutButtonProps = {
10+
/** True while the correlated trace hasn't resolved; the button is disabled. */
11+
disabled: boolean;
12+
/** Navigate to the correlated trace. Only reachable when not disabled. */
13+
onView: () => void;
14+
/**
15+
* Whether the nudge has already auto-opened during this panel-open lifecycle.
16+
* Owned by the parent so it survives the per-row loading gate that unmounts
17+
* this button (see `onAutoOpen`).
18+
*/
19+
autoOpened: boolean;
20+
/** Raise the auto-open latch in the parent the first time a trace resolves. */
21+
onAutoOpen: () => void;
22+
};
23+
24+
/**
25+
* The prominent "View trace" action shown in a log side panel, wrapped in a
26+
* one-time nudge popover.
27+
*
28+
* The callout is dismissed only by an explicit acknowledgement — clicking
29+
* "Got it" or the button itself — which persists under its own localStorage key
30+
* (not user settings). It deliberately does not close on stray clicks, drawer
31+
* resizes, or Escape: Escape stays owned by the panel-level hotkey so the
32+
* callout never fights it for the keypress. Because nothing transient is
33+
* stored, the nudge simply reappears the next time the panel opens on an
34+
* eligible log until it is acknowledged.
35+
*
36+
* Auto-open is latched for the panel-open lifecycle (via `autoOpened`/
37+
* `onAutoOpen`) rather than derived directly from `disabled`, so paging between
38+
* rows doesn't reopen the callout as each row's trace re-resolves. The latch
39+
* lives in the parent because the side panel unmounts this button behind a
40+
* "Loading..." gate while each new row loads, which would reset any state held
41+
* here.
42+
*/
43+
export function ViewTraceCalloutButton({
44+
disabled,
45+
onView,
46+
autoOpened,
47+
onAutoOpen,
48+
}: ViewTraceCalloutButtonProps) {
49+
const [dismissed, setDismissed] = useLocalStorage(
50+
VIEW_TRACE_CALLOUT_DISMISSED_KEY,
51+
false,
52+
);
53+
const dismiss = useCallback(() => setDismissed(true), [setDismissed]);
54+
55+
useEffect(() => {
56+
if (!disabled && !dismissed && !autoOpened) {
57+
onAutoOpen();
58+
}
59+
}, [disabled, dismissed, autoOpened, onAutoOpen]);
60+
const opened = autoOpened && !dismissed;
61+
62+
return (
63+
<Popover
64+
width={260}
65+
position="bottom-end"
66+
withArrow
67+
shadow="md"
68+
trapFocus={false}
69+
// Intentional: an outside click neither dismisses the callout nor is it
70+
// treated as an accidental dismissal. This is a one-time, one-line hint
71+
// that only ever covers a small strip below the button; it is dismissed
72+
// deliberately via "Got it" or by clicking View Trace, and does not
73+
// reappear once acknowledged. We accept that the covered controls are
74+
// briefly non-interactive so a stray click can't burn the message before
75+
// it is read. (Reviewed: PR #2815 — kept by design.)
76+
closeOnClickOutside={false}
77+
closeOnEscape={false}
78+
opened={opened}
79+
>
80+
<Popover.Target>
81+
<Button
82+
data-testid="side-panel-view-trace"
83+
variant="secondary"
84+
size="compact-sm"
85+
ml="auto"
86+
leftSection={<IconConnection size={14} />}
87+
rightSection={<IconArrowRight size={14} />}
88+
onClick={() => {
89+
dismiss();
90+
onView();
91+
}}
92+
disabled={disabled}
93+
>
94+
View Trace
95+
</Button>
96+
</Popover.Target>
97+
<Popover.Dropdown
98+
data-testid="view-trace-callout"
99+
role="status"
100+
aria-live="polite"
101+
>
102+
<Stack gap="xs">
103+
<Text size="sm" fw={600}>
104+
Jump to this log&apos;s full trace
105+
</Text>
106+
<Text size="xs" c="dimmed">
107+
Open the correlated trace in one click.
108+
</Text>
109+
<Button
110+
variant="primary"
111+
size="compact-xs"
112+
ml="auto"
113+
onClick={dismiss}
114+
>
115+
Got it
116+
</Button>
117+
</Stack>
118+
</Popover.Dropdown>
119+
</Popover>
120+
);
121+
}

0 commit comments

Comments
 (0)