feat(call-recorder): add copy-to-clipboard buttons for transcript, summary, and video link (#23052)

## Context

Closes twentyhq/core-team-issues#2692.

Adds copy-to-clipboard actions to the call recorder app so users can
quickly share a call's transcript, summary, and video.

## What changed

- **Copy transcript** button in the *Recording and Transcript* widget
header. Copies the transcript as plain text with resolved speaker
display names and timestamps (mirroring what is shown on screen).
- **Copy video download link** button in the same header. Copies the
signed video file URL.
- **Copy summary** button in the *Summary* widget header. Copies the
summary markdown.

Each button is powered by a new reusable `CopyToClipboardButton`
component that writes to the clipboard, briefly swaps to a check icon
for feedback, and surfaces a success/error snackbar. Buttons are
disabled when there is nothing to copy (no transcript / video / summary,
or while loading).

A `buildTranscriptPlainText` utility turns parsed transcript entries
into shareable text, with participant display names preferred over raw
diarized speaker labels.

## Screenshots

The *Recording and Transcript* header now shows a copy-transcript and a
copy-video-link button, and the *Summary* header shows a copy-summary
button.

| Light | Dark |
| --- | --- |
| <img width="426"
src="https://raw.githubusercontent.com/twentyhq/twenty/claude/issue-2692-screenshots/.github/pr-screenshots/2692/call-recorder-copy-buttons-light.png"
/> | <img width="426"
src="https://raw.githubusercontent.com/twentyhq/twenty/claude/issue-2692-screenshots/.github/pr-screenshots/2692/call-recorder-copy-buttons-dark.png"
/> |

## Tests

- New unit tests for `buildTranscriptPlainText` (speaker/timestamp
formatting, missing timestamps, participant name resolution).
- Full app unit suite passes (491 tests), plus typecheck and lint.
This commit is contained in:
martmull
2026-07-20 17:40:17 +02:00
committed by GitHub
parent f86820552c
commit 1b5e974629
5 changed files with 211 additions and 1 deletions
@@ -1,10 +1,15 @@
import styled from '@emotion/styled';
import { useState } from 'react';
import { isNonEmptyArray } from '@sniptt/guards';
import { useMemo, useState } from 'react';
import { IconLink } from 'twenty-ui/icon';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { CalendarEventRecordingBody } from 'src/front-components/components/CalendarEventRecordingBody';
import { CopyToClipboardButton } from 'src/front-components/components/CopyToClipboardButton';
import { useCalendarEventParticipants } from 'src/front-components/hooks/use-calendar-event-participants';
import { useCalendarEventRecording } from 'src/front-components/hooks/use-calendar-event-recording';
import { buildTranscriptPlainText } from 'src/front-components/utils/build-transcript-plain-text.util';
import { parseTranscriptEntries } from 'src/front-components/utils/parse-transcript-entries.util';
const TRANSCRIPT_TIME_UPDATE_INTERVAL_SECONDS = 0.25;
@@ -38,6 +43,12 @@ const StyledRecordingTitle = styled.h2`
user-select: none;
`;
const StyledRecordingHeaderActions = styled.div`
align-items: center;
display: flex;
gap: ${() => themeCssVariables.spacing[1]};
`;
const StyledRecordingBody = styled.div`
box-sizing: border-box;
margin-top: ${() => themeCssVariables.spacing[2]};
@@ -90,10 +101,31 @@ export const CalendarEventRecordingContent = ({
const videoFileUrl = videoFile?.url ?? undefined;
const transcriptPlainText = useMemo(() => {
const entries = parseTranscriptEntries(transcript);
if (!isNonEmptyArray(entries)) {
return undefined;
}
return buildTranscriptPlainText({ entries, calendarEventParticipants });
}, [transcript, calendarEventParticipants]);
return (
<StyledRecordingShell>
<StyledRecordingHeader>
<StyledRecordingTitle>Recording and Transcript</StyledRecordingTitle>
<StyledRecordingHeaderActions>
<CopyToClipboardButton
textToCopy={transcriptPlainText}
ariaLabel="Copy transcript"
/>
<CopyToClipboardButton
textToCopy={videoFileUrl}
ariaLabel="Copy video download link"
Icon={IconLink}
/>
</StyledRecordingHeaderActions>
</StyledRecordingHeader>
<StyledRecordingBody>
<StyledRecordingContentFrame>
@@ -2,6 +2,7 @@ import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { CalendarEventSummaryBody } from 'src/front-components/components/CalendarEventSummaryBody';
import { CopyToClipboardButton } from 'src/front-components/components/CopyToClipboardButton';
import { useCalendarEventSummary } from 'src/front-components/hooks/use-calendar-event-summary';
const StyledSummaryShell = styled.div`
@@ -60,6 +61,10 @@ export const CalendarEventSummaryContent = ({
<StyledSummaryShell>
<StyledSummaryHeader>
<StyledSummaryTitle>Summary</StyledSummaryTitle>
<CopyToClipboardButton
textToCopy={summaryMarkdown}
ariaLabel="Copy summary"
/>
</StyledSummaryHeader>
<StyledSummaryBody>
<StyledSummaryContentFrame>
@@ -0,0 +1,67 @@
import styled from '@emotion/styled';
import { copyToClipboard } from 'twenty-sdk/front-component';
import { IconCopy, type IconComponent } from 'twenty-ui/icon';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const COPY_ICON_SIZE = 16;
const StyledButton = styled.button`
align-items: center;
background: transparent;
border: 1px solid ${() => themeCssVariables.border.color.medium};
border-radius: ${() => themeCssVariables.border.radius.sm};
color: ${() => themeCssVariables.font.color.secondary};
cursor: pointer;
display: flex;
height: ${() => themeCssVariables.spacing[6]};
justify-content: center;
padding: 0 ${() => themeCssVariables.spacing[1]};
transition: background 0.1s ease;
width: ${() => themeCssVariables.spacing[6]};
&:hover:not(:disabled) {
background: ${() => themeCssVariables.background.tertiary};
}
&:disabled {
color: ${() => themeCssVariables.font.color.light};
cursor: not-allowed;
}
`;
type CopyToClipboardButtonProps = {
textToCopy: string | undefined;
ariaLabel: string;
Icon?: IconComponent;
};
export const CopyToClipboardButton = ({
textToCopy,
ariaLabel,
Icon = IconCopy,
}: CopyToClipboardButtonProps) => {
const isDisabled = textToCopy === undefined || textToCopy.length === 0;
const handleClick = () => {
if (isDisabled) {
return;
}
// The host performs the clipboard write and owns the success/error
// snackbar; the front-component sandbox has no direct navigator.clipboard
// access, and the host does not report back whether the copy succeeded.
void copyToClipboard(textToCopy);
};
return (
<StyledButton
type="button"
aria-label={ariaLabel}
title={ariaLabel}
disabled={isDisabled}
onClick={handleClick}
>
<Icon size={COPY_ICON_SIZE} />
</StyledButton>
);
};
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type';
import { type TranscriptEntry } from 'src/front-components/types/transcript-entry.type';
import { buildTranscriptPlainText } from 'src/front-components/utils/build-transcript-plain-text.util';
const buildEntry = (
overrides: Partial<TranscriptEntry> & Pick<TranscriptEntry, 'speakerName'>,
): TranscriptEntry => ({
startSeconds: undefined,
endSeconds: undefined,
text: '',
words: [],
...overrides,
});
describe('buildTranscriptPlainText', () => {
it('renders each entry with speaker, timestamp, and text separated by blank lines', () => {
const entries: TranscriptEntry[] = [
buildEntry({
speakerName: 'Ada Lovelace',
startSeconds: 72,
text: 'Hello there',
}),
buildEntry({
speakerName: 'Grace Hopper',
startSeconds: 130,
text: 'Hi',
}),
];
expect(
buildTranscriptPlainText({ entries, calendarEventParticipants: [] }),
).toBe('Ada Lovelace (1:12)\nHello there\n\nGrace Hopper (2:10)\nHi');
});
it('omits the timestamp when the entry has no start time', () => {
const entries: TranscriptEntry[] = [
buildEntry({ speakerName: 'Ada Lovelace', text: 'Hello there' }),
];
expect(
buildTranscriptPlainText({ entries, calendarEventParticipants: [] }),
).toBe('Ada Lovelace\nHello there');
});
it('prefers the matched participant display name over the raw speaker label', () => {
const entries: TranscriptEntry[] = [
buildEntry({
speakerName: 'ada lovelace',
startSeconds: 0,
text: 'Hello there',
}),
];
const calendarEventParticipants: CalendarEventRecordingParticipant[] = [
{
id: 'participant-1',
avatarUrl: undefined,
displayName: 'Ada L.',
nameCandidates: ['Ada Lovelace'],
placeholderColorSeed: 'participant-1',
},
];
expect(
buildTranscriptPlainText({ entries, calendarEventParticipants }),
).toBe('Ada L. (0:00)\nHello there');
});
});
@@ -0,0 +1,37 @@
import { isUndefined } from '@sniptt/guards';
import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type';
import { type TranscriptEntry } from 'src/front-components/types/transcript-entry.type';
import { buildCalendarEventParticipantBySpeakerName } from 'src/front-components/utils/build-calendar-event-participant-by-speaker-name.util';
import { getCalendarEventParticipantForSpeakerName } from 'src/front-components/utils/get-calendar-event-participant-for-speaker-name.util';
import { formatSecondsAsClockTimestamp } from 'src/logic-functions/utils/format-seconds-as-clock-timestamp.util';
// Mirrors the on-screen transcript: participant display name resolution wins
// over the raw diarized speaker label so the copied text matches what is read.
export const buildTranscriptPlainText = ({
entries,
calendarEventParticipants,
}: {
entries: TranscriptEntry[];
calendarEventParticipants: CalendarEventRecordingParticipant[];
}): string => {
const calendarEventParticipantBySpeakerName =
buildCalendarEventParticipantBySpeakerName(calendarEventParticipants);
return entries
.map((entry) => {
const calendarEventParticipant =
getCalendarEventParticipantForSpeakerName({
speakerName: entry.speakerName,
calendarEventParticipantBySpeakerName,
});
const speakerDisplayName =
calendarEventParticipant?.displayName ?? entry.speakerName;
const timestamp = isUndefined(entry.startSeconds)
? ''
: ` (${formatSecondsAsClockTimestamp(entry.startSeconds)})`;
return `${speakerDisplayName}${timestamp}\n${entry.text}`;
})
.join('\n\n');
};