Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/thin-bikes-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperdx/app': patch
---

fix(app): copy the latest session URL from the Share Session button
72 changes: 53 additions & 19 deletions packages/app/src/SessionSidePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import CopyToClipboard from 'react-copy-to-clipboard';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import {
DateRange,
Expand All @@ -17,6 +16,10 @@ import {
getInitialDrawerWidthPercent,
} from '@/components/DrawerUtils';
import useResizable from '@/hooks/useResizable';
import {
CLIPBOARD_ERROR_MESSAGE,
copyTextToClipboard,
} from '@/utils/clipboard';

import { Session } from './sessions';
import SessionSubpanel from './SessionSubpanel';
Expand Down Expand Up @@ -49,6 +52,18 @@ export default function SessionSidePanel({
}) {
// Keep track of sub-drawers so we can disable closing this root drawer
const [subDrawerOpen, setSubDrawerOpen] = useState(false);
const [isSharingSession, setIsSharingSession] = useState(false);
const isSharingSessionRef = useRef(false);
const isMountedRef = useRef(true);

useEffect(() => {
// React 18 StrictMode runs cleanup before re-mounting this effect.
isMountedRef.current = true;

return () => {
isMountedRef.current = false;
};
}, []);
Comment on lines +59 to +66
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need this ref, I'm not sure what it's guarding against? The state update and call to notifications are ok without it


const { size, setSize, startResize } = useResizable(
getInitialDrawerWidthPercent(),
Expand All @@ -58,6 +73,33 @@ export default function SessionSidePanel({
setSize(isFullWidth ? getInitialDrawerWidthPercent() : 100);
}, [isFullWidth, setSize]);

const handleShareSession = useCallback(async () => {
if (isSharingSessionRef.current) {
return;
}

isSharingSessionRef.current = true;
setIsSharingSession(true);
let copied = false;
try {
copied = await copyTextToClipboard(window.location.href);
} catch {
copied = false;
Comment on lines +84 to +87
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

copyTextToClipboard already wraps both the navigator.clipboard.writeText path and the execCommand fallback in their own try/catch, so I don't think this is needed here.

} finally {
isSharingSessionRef.current = false;
if (isMountedRef.current) {
setIsSharingSession(false);
}
}
if (!isMountedRef.current) {
return;
}
notifications.show({
color: copied ? 'green' : 'red',
message: copied ? 'Copied link to clipboard' : CLIPBOARD_ERROR_MESSAGE,
});
}, []);
Comment on lines +76 to +101
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be simplified to:

const handleShareSession = useCallback(async () => {
  if (isSharingSessionRef.current) return;
  isSharingSessionRef.current = true;
  setIsSharingSession(true);

  const copied = await copyTextToClipboard(window.location.href);

  isSharingSessionRef.current = false;
  setIsSharingSession(false);
  notifications.show({
    color: copied ? 'green' : 'red',
    message: copied ? 'Copied link to clipboard' : CLIPBOARD_ERROR_MESSAGE,
  });
}, []);


useHotkeys(
['esc'],
() => {
Expand Down Expand Up @@ -125,24 +167,16 @@ export default function SessionSidePanel({
isFullWidth={isFullWidth}
onToggle={toggleFullWidth}
/>
<CopyToClipboard
text={window.location.href}
onCopy={() => {
notifications.show({
color: 'green',
message: 'Copied link to clipboard',
});
}}
<Button
variant="secondary"
size="sm"
leftSection={<IconLink size={14} />}
style={{ fontSize: '12px' }}
loading={isSharingSession}
onClick={handleShareSession}
>
<Button
variant="secondary"
size="sm"
leftSection={<IconLink size={14} />}
style={{ fontSize: '12px' }}
>
Share Session
</Button>
</CopyToClipboard>
Share Session
</Button>
<ActionIcon variant="secondary" size="md" onClick={onClose}>
<IconX size={14} />
</ActionIcon>
Expand Down
268 changes: 268 additions & 0 deletions packages/app/src/__tests__/SessionSidePanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import { StrictMode } from 'react';
import {
SourceKind,
TSessionSource,
TTraceSource,
} from '@hyperdx/common-utils/dist/types';
import { notifications } from '@mantine/notifications';
import { fireEvent, screen, waitFor } from '@testing-library/react';

import { Session } from '../sessions';
import SessionSidePanel from '../SessionSidePanel';
import {
CLIPBOARD_ERROR_MESSAGE,
copyTextToClipboard,
} from '../utils/clipboard';

jest.mock(
'../SessionSubpanel',
() =>
function MockSessionSubpanel() {
return <div data-testid="session-subpanel" />;
},
);

jest.mock('@/hooks/useResizable', () => ({
__esModule: true,
default: () => ({
size: 50,
setSize: jest.fn(),
startResize: jest.fn(),
}),
}));

jest.mock('../utils/clipboard', () => ({
...jest.requireActual('../utils/clipboard'),
copyTextToClipboard: jest.fn(),
}));

const copyTextToClipboardMock = jest.mocked(copyTextToClipboard);
const notificationsShowSpy = jest
.spyOn(notifications, 'show')
.mockImplementation(jest.fn());

const traceSource = {
id: 'trace-source',
name: 'Trace Source',
kind: SourceKind.Trace,
connection: 'clickhouse',
from: {
databaseName: 'default',
tableName: 'traces',
},
timestampValueExpression: 'Timestamp',
defaultTableSelectExpression: '*',
durationExpression: 'Duration',
durationPrecision: 9,
traceIdExpression: 'TraceId',
spanIdExpression: 'SpanId',
parentSpanIdExpression: 'ParentSpanId',
spanNameExpression: 'SpanName',
spanKindExpression: 'SpanKind',
} satisfies TTraceSource;

const sessionSource = {
id: 'session-source',
name: 'Session Source',
kind: SourceKind.Session,
connection: 'clickhouse',
from: {
databaseName: 'default',
tableName: 'sessions',
},
timestampValueExpression: 'Timestamp',
traceSourceId: traceSource.id,
} satisfies TSessionSource;

const session = {
userEmail: 'user@example.com',
maxTimestamp: '2026-05-21T10:00:00Z',
minTimestamp: '2026-05-21T09:00:00Z',
errorCount: '0',
interactionCount: '0',
recordingCount: '0',
serviceName: 'web',
sessionCount: '12',
sessionId: 'session-1',
teamId: 'team-1',
teamName: 'Team',
userName: 'User',
} satisfies Session;

function renderPanel({ strict = false }: { strict?: boolean } = {}) {
const panel = (
<SessionSidePanel
traceSource={traceSource}
sessionSource={sessionSource}
sessionId="session-1"
session={session}
dateRange={[
new Date('2026-05-21T09:00:00Z'),
new Date('2026-05-21T10:00:00Z'),
]}
onClose={jest.fn()}
/>
);

return renderWithMantine(strict ? <StrictMode>{panel}</StrictMode> : panel);
}

describe('SessionSidePanel', () => {
beforeEach(() => {
copyTextToClipboardMock.mockResolvedValue(true);
window.history.pushState(
{},
'',
'/sessions?sessionSource=source-1&from=1&to=2',
);
});

afterEach(() => {
jest.clearAllMocks();
});

it('copies the current session URL when the share button is clicked', async () => {
renderPanel();

window.history.pushState(
{},
'',
'/sessions?sessionSource=source-1&from=1&to=2&sid=session-1&sfrom=10&sto=20',
);

fireEvent.click(screen.getByRole('button', { name: /share session/i }));

await waitFor(() => {
expect(copyTextToClipboardMock).toHaveBeenCalledWith(
'http://localhost/sessions?sessionSource=source-1&from=1&to=2&sid=session-1&sfrom=10&sto=20',
);
expect(notificationsShowSpy).toHaveBeenCalledWith({
color: 'green',
message: 'Copied link to clipboard',
});
});

window.history.pushState(
{},
'',
'/sessions?sessionSource=source-1&from=1&to=2&sid=session-1&sfrom=30&sto=40',
);

fireEvent.click(screen.getByRole('button', { name: /share session/i }));

await waitFor(() => {
expect(copyTextToClipboardMock).toHaveBeenLastCalledWith(
'http://localhost/sessions?sessionSource=source-1&from=1&to=2&sid=session-1&sfrom=30&sto=40',
);
});
});

it('shows an error notification when copying the session URL fails', async () => {
copyTextToClipboardMock.mockResolvedValue(false);
renderPanel();

fireEvent.click(screen.getByRole('button', { name: /share session/i }));

await waitFor(() => {
expect(copyTextToClipboardMock).toHaveBeenCalledWith(
'http://localhost/sessions?sessionSource=source-1&from=1&to=2',
);
expect(notificationsShowSpy).toHaveBeenCalledWith({
color: 'red',
message: CLIPBOARD_ERROR_MESSAGE,
});
});
});

it('shows an error notification when copying the session URL rejects', async () => {
copyTextToClipboardMock.mockRejectedValue(new Error('clipboard blocked'));
renderPanel();

fireEvent.click(screen.getByRole('button', { name: /share session/i }));

await waitFor(() => {
expect(copyTextToClipboardMock).toHaveBeenCalledWith(
'http://localhost/sessions?sessionSource=source-1&from=1&to=2',
);
expect(notificationsShowSpy).toHaveBeenCalledWith({
color: 'red',
message: CLIPBOARD_ERROR_MESSAGE,
});
});
});

it('ignores duplicate share clicks while copying is still pending', async () => {
let finishCopy: (copied: boolean) => void = (_copied: boolean): void => {
throw new Error('copy promise was not created');
};
copyTextToClipboardMock.mockImplementation(
() =>
new Promise(resolve => {
finishCopy = resolve;
}),
);
renderPanel();

const shareButton = screen.getByRole('button', { name: /share session/i });
fireEvent.click(shareButton);
fireEvent.click(shareButton);

expect(copyTextToClipboardMock).toHaveBeenCalledTimes(1);

finishCopy(true);

await waitFor(() => {
expect(notificationsShowSpy).toHaveBeenCalledTimes(1);
expect(notificationsShowSpy).toHaveBeenCalledWith({
color: 'green',
message: 'Copied link to clipboard',
});
});

fireEvent.click(shareButton);

expect(copyTextToClipboardMock).toHaveBeenCalledTimes(2);

finishCopy(true);

await waitFor(() => {
expect(notificationsShowSpy).toHaveBeenCalledTimes(2);
});
});

it('does not show a share notification after the panel unmounts', async () => {
let finishCopy: (copied: boolean) => void = (_copied: boolean): void => {
throw new Error('copy promise was not created');
};
copyTextToClipboardMock.mockImplementation(
() =>
new Promise(resolve => {
finishCopy = resolve;
}),
);
const { unmount } = renderPanel();

fireEvent.click(screen.getByRole('button', { name: /share session/i }));

expect(copyTextToClipboardMock).toHaveBeenCalledTimes(1);

unmount();
finishCopy(true);
await new Promise(resolve => setTimeout(resolve, 0));
expect(notificationsShowSpy).not.toHaveBeenCalled();
});

it('shows share notifications when rendered in StrictMode', async () => {
renderPanel({ strict: true });

fireEvent.click(screen.getByRole('button', { name: /share session/i }));

await waitFor(() => {
expect(copyTextToClipboardMock).toHaveBeenCalledTimes(1);
expect(notificationsShowSpy).toHaveBeenCalledWith({
color: 'green',
message: 'Copied link to clipboard',
});
});
});
});
Loading