Skip to content
Open
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
53 changes: 34 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, 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,7 @@ export default function SessionSidePanel({
}) {
// Keep track of sub-drawers so we can disable closing this root drawer
const [subDrawerOpen, setSubDrawerOpen] = useState(false);
const isSharingSessionRef = useRef(false);

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

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

isSharingSessionRef.current = true;
let copied = false;
try {
copied = await copyTextToClipboard(window.location.href);
} catch {
copied = false;
} finally {
isSharingSessionRef.current = false;
}
notifications.show({
color: copied ? 'green' : 'red',
message: copied ? 'Copied link to clipboard' : CLIPBOARD_ERROR_MESSAGE,
});
}, []);

useHotkeys(
['esc'],
() => {
Expand Down Expand Up @@ -125,24 +149,15 @@ 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' }}
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
178 changes: 178 additions & 0 deletions packages/app/src/__tests__/SessionSidePanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { notifications } from '@mantine/notifications';
import { fireEvent, screen, waitFor } from '@testing-library/react';

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 = copyTextToClipboard as jest.Mock;
const notificationsShowSpy = jest
.spyOn(notifications, 'show')
.mockImplementation(jest.fn());

function renderPanel() {
return renderWithMantine(
<SessionSidePanel
traceSource={{} as any}
sessionSource={{} as any}
sessionId="session-1"
session={
{
userEmail: 'user@example.com',
maxTimestamp: '2026-05-21T10:00:00Z',
errorCount: '0',
sessionCount: '12',
} as any
}
dateRange={[
new Date('2026-05-21T09:00:00Z'),
new Date('2026-05-21T10:00:00Z'),
]}
onClose={jest.fn()}
/>,
);
}

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',
});
});
});

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 the clipboard helper rejects', async () => {
copyTextToClipboardMock
.mockRejectedValueOnce(new Error('copy failed'))
.mockResolvedValueOnce(true);
renderPanel();

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

await waitFor(() => {
expect(notificationsShowSpy).toHaveBeenCalledWith({
color: 'red',
message: CLIPBOARD_ERROR_MESSAGE,
});
});

fireEvent.click(shareButton);

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

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);
});
});
});
Loading