diff --git a/packages/twenty-apps/internal/call-recording/.gitignore b/packages/twenty-apps/internal/call-recording/.gitignore
new file mode 100644
index 0000000000..f79321f3f2
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/.gitignore
@@ -0,0 +1,42 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn
+
+# codegen
+generated
+
+# testing
+/coverage
+
+# dev
+/dist/
+
+.twenty/*
+!.twenty/output/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+
+# typescript
+*.tsbuildinfo
+
+
+# Remove once prod ready
+.twenty
diff --git a/packages/twenty-apps/internal/call-recording/.nvmrc b/packages/twenty-apps/internal/call-recording/.nvmrc
new file mode 100644
index 0000000000..341cb50613
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/.nvmrc
@@ -0,0 +1 @@
+24.5.0
diff --git a/packages/twenty-apps/internal/call-recording/.yarnrc.yml b/packages/twenty-apps/internal/call-recording/.yarnrc.yml
new file mode 100644
index 0000000000..3186f3f079
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/.yarnrc.yml
@@ -0,0 +1 @@
+nodeLinker: node-modules
diff --git a/packages/twenty-apps/internal/call-recording/LLMS.md b/packages/twenty-apps/internal/call-recording/LLMS.md
new file mode 100644
index 0000000000..421003481f
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/LLMS.md
@@ -0,0 +1,9 @@
+## Base documentation
+
+- Documentation: https://docs.twenty.com/developers/extend/capabilities/apps
+- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-sdk/src/cli/__tests__/apps/rich-app
+
+## Common Pitfalls
+
+- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
+- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
diff --git a/packages/twenty-apps/internal/call-recording/README.md b/packages/twenty-apps/internal/call-recording/README.md
new file mode 100644
index 0000000000..f12837b91e
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/README.md
@@ -0,0 +1,51 @@
+This is a [Twenty](https://twenty.com) application project bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
+
+## Getting Started
+
+First, authenticate to your workspace:
+
+```bash
+yarn twenty auth:login
+```
+
+Then, start development mode to sync your app and watch for changes:
+
+```bash
+yarn twenty app:dev
+```
+
+Open your Twenty instance and go to `/settings/applications` section to see the result.
+
+## Available Commands
+
+Run `yarn twenty help` to list all available commands. Common commands:
+
+```bash
+# Authentication
+yarn twenty auth:login # Authenticate with Twenty
+yarn twenty auth:logout # Remove credentials
+yarn twenty auth:status # Check auth status
+yarn twenty auth:switch # Switch default workspace
+yarn twenty auth:list # List all configured workspaces
+
+# Application
+yarn twenty app:dev # Start dev mode (watch, build, sync, and auto-generate typed client)
+yarn twenty entity:add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
+yarn twenty function:logs # Stream function logs
+yarn twenty function:execute # Execute a function with JSON payload
+yarn twenty app:uninstall # Uninstall app from workspace
+```
+
+## LLMs instructions
+
+Main docs and pitfalls are available in LLMS.md file.
+
+## Learn More
+
+To learn more about Twenty applications, take a look at the following resources:
+
+- [twenty-sdk](https://www.npmjs.com/package/twenty-sdk) - learn about `twenty-sdk` tool.
+- [Twenty doc](https://docs.twenty.com/) - Twenty's documentation.
+- Join our [Discord](https://discord.gg/cx5n4Jzs57)
+
+You can check out [the Twenty GitHub repository](https://github.com/twentyhq/twenty) - your feedback and contributions are welcome!
diff --git a/packages/twenty-apps/internal/call-recording/eslint.config.mjs b/packages/twenty-apps/internal/call-recording/eslint.config.mjs
new file mode 100644
index 0000000000..825aefbfcf
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/eslint.config.mjs
@@ -0,0 +1,29 @@
+import js from '@eslint/js';
+import tseslint from 'typescript-eslint';
+
+export default [
+ // Base JS recommended rules
+ js.configs.recommended,
+
+ // TypeScript recommended rules
+ ...tseslint.configs.recommended,
+
+ {
+ files: ['**/*.ts', '**/*.tsx'],
+ languageOptions: {
+ parserOptions: {
+ project: true,
+ tsconfigRootDir: import.meta.dirname,
+ },
+ },
+ rules: {
+ // Common TypeScript-friendly tweaks
+ '@typescript-eslint/no-unused-vars': [
+ 'warn',
+ { argsIgnorePattern: '^_' },
+ ],
+ '@typescript-eslint/no-explicit-any': 'off',
+ 'no-unused-vars': 'off', // handled by TS rule
+ },
+ },
+];
diff --git a/packages/twenty-apps/internal/call-recording/package.json b/packages/twenty-apps/internal/call-recording/package.json
new file mode 100644
index 0000000000..81136851c7
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "call-recording",
+ "version": "0.1.0",
+ "license": "MIT",
+ "engines": {
+ "node": "^24.5.0",
+ "npm": "please-use-yarn",
+ "yarn": ">=4.0.2"
+ },
+ "packageManager": "yarn@4.9.2",
+ "scripts": {
+ "twenty": "twenty",
+ "lint": "eslint",
+ "lint:fix": "eslint --fix"
+ },
+ "dependencies": {
+ "@emotion/react": "^11.11.1",
+ "@emotion/styled": "^11.11.0",
+ "react-loading-skeleton": "^3.5.0",
+ "react-markdown": "^10.1.0",
+ "twenty-sdk": "0.6.3-alpha"
+ },
+ "devDependencies": {
+ "@types/node": "^24.7.2",
+ "@types/react": "^18.2.0",
+ "eslint": "^9.32.0",
+ "react": "^18.2.0",
+ "typescript": "^5.9.3",
+ "typescript-eslint": "^8.50.0"
+ }
+}
diff --git a/packages/twenty-apps/internal/call-recording/src/application-config.ts b/packages/twenty-apps/internal/call-recording/src/application-config.ts
new file mode 100644
index 0000000000..9aa4e81a4c
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/src/application-config.ts
@@ -0,0 +1,9 @@
+import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
+import { defineApplication } from 'twenty-sdk';
+
+export default defineApplication({
+ universalIdentifier: '4daa5147-7e70-4e43-b091-c27e1e8a32e3',
+ displayName: 'Call recording',
+ description: 'Allows to record calls',
+ defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
+});
diff --git a/packages/twenty-apps/internal/call-recording/src/components/AudioPlayer.tsx b/packages/twenty-apps/internal/call-recording/src/components/AudioPlayer.tsx
new file mode 100644
index 0000000000..0662aee65e
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/src/components/AudioPlayer.tsx
@@ -0,0 +1,54 @@
+import styled from '@emotion/styled';
+
+import { SerializedEventData } from 'twenty-sdk/dist/sdk/front-component-api';
+
+const StyledAudioWrapper = styled.div`
+ background: linear-gradient(135deg, #f8f9fb 0%, #eef0f4 100%);
+ border-radius: 12px;
+ padding: 20px;
+ display: flex;
+ align-items: center;
+ border: 1px solid rgba(0, 0, 0, 0.06);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
+`;
+
+const StyledAudio = styled.audio`
+ width: 100%;
+ height: 36px;
+ border-radius: 8px;
+ outline: none;
+
+ &::-webkit-media-controls-panel {
+ background: transparent;
+ }
+`;
+
+type AudioPlayerProps = {
+ src: string;
+ extension: string;
+ onTimeUpdate?: (currentTimeSeconds: number) => void;
+};
+
+export const AudioPlayer = ({
+ src,
+ extension,
+ onTimeUpdate,
+}: AudioPlayerProps) => {
+ return (
+
+ {
+ const currentTime = (event as CustomEvent)
+ .detail.currentTime;
+
+ if (typeof currentTime === 'number') {
+ onTimeUpdate?.(currentTime);
+ }
+ }}
+ >
+
+
+
+ );
+};
diff --git a/packages/twenty-apps/internal/call-recording/src/components/CallRecordingViewerSkeleton.tsx b/packages/twenty-apps/internal/call-recording/src/components/CallRecordingViewerSkeleton.tsx
new file mode 100644
index 0000000000..718efbd32d
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/src/components/CallRecordingViewerSkeleton.tsx
@@ -0,0 +1,46 @@
+import styled from '@emotion/styled';
+import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
+
+import {
+ SKELETON_BASE_COLOR,
+ SKELETON_HIGHLIGHT_COLOR,
+ StyledSummarySkeletonContainer,
+ StyledViewerSkeletonContainer,
+} from 'src/constants/skeleton-constants';
+
+const StyledMediaSkeletonCard = styled.div`
+ border-radius: 12px;
+ overflow: hidden;
+ border: 1px solid rgba(0, 0, 0, 0.06);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
+`;
+
+const StyledTranscriptSkeletonCard = styled.div`
+ background: #ffffff;
+ border-radius: 12px;
+ border: 1px solid rgba(0, 0, 0, 0.06);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
+ overflow: hidden;
+`;
+
+export const CallRecordingViewerSkeleton = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/twenty-apps/internal/call-recording/src/components/MediaPlayer.tsx b/packages/twenty-apps/internal/call-recording/src/components/MediaPlayer.tsx
new file mode 100644
index 0000000000..ca2fdff8e0
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/src/components/MediaPlayer.tsx
@@ -0,0 +1,40 @@
+import { AudioPlayer } from 'src/components/AudioPlayer';
+import { VideoPlayer } from 'src/components/VideoPlayer';
+import { isAudioExtension } from 'src/utils/is-audio-extension';
+import { isVideoExtension } from 'src/utils/is-video-extension';
+
+type MediaPlayerProps = {
+ url: string;
+ extension: string;
+ onTimeUpdate?: (currentTimeSeconds: number) => void;
+};
+
+export const MediaPlayer = ({
+ url,
+ extension,
+ onTimeUpdate,
+}: MediaPlayerProps) => {
+ const normalizedExtension = extension.toLowerCase().replace(/^\./, '');
+
+ if (isAudioExtension(normalizedExtension)) {
+ return (
+
+ );
+ }
+
+ if (isVideoExtension(normalizedExtension)) {
+ return (
+
+ );
+ }
+
+ throw new Error('Unsupported file extension');
+};
diff --git a/packages/twenty-apps/internal/call-recording/src/components/SummaryViewer.tsx b/packages/twenty-apps/internal/call-recording/src/components/SummaryViewer.tsx
new file mode 100644
index 0000000000..62dd1a7388
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/src/components/SummaryViewer.tsx
@@ -0,0 +1,107 @@
+import styled from '@emotion/styled';
+import Markdown from 'react-markdown';
+import { isDefined } from 'twenty-shared/utils';
+
+type SummaryViewerProps = {
+ markdown: string | null | undefined;
+};
+
+const StyledSummaryCard = styled.div`
+ background: #ffffff;
+ border-radius: 12px;
+ overflow: hidden;
+`;
+
+const StyledSummaryContent = styled.div`
+ line-height: 1.7;
+ padding: 20px 24px;
+ font-size: 13.5px;
+ color: #333;
+ max-height: 500px;
+ overflow-y: auto;
+
+ &::-webkit-scrollbar {
+ width: 6px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: rgba(0, 0, 0, 0.12);
+ border-radius: 3px;
+ }
+
+ &::-webkit-scrollbar-thumb:hover {
+ background: rgba(0, 0, 0, 0.2);
+ }
+
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ margin-top: 1.2em;
+ margin-bottom: 0.5em;
+ color: #1a1a1a;
+ font-weight: 600;
+
+ &:first-child {
+ margin-top: 0;
+ }
+ }
+
+ p {
+ margin: 0.6em 0;
+ }
+
+ strong {
+ color: #1a1a1a;
+ font-weight: 600;
+ }
+
+ ul,
+ ol {
+ padding-left: 1.5em;
+ margin: 0.5em 0;
+ }
+
+ code {
+ background-color: rgba(0, 0, 0, 0.04);
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 0.88em;
+ font-family: 'SF Mono', 'Fira Code', monospace;
+ }
+
+ pre {
+ background-color: #f6f8fa;
+ padding: 14px 16px;
+ border-radius: 8px;
+ overflow-x: auto;
+ border: 1px solid rgba(0, 0, 0, 0.06);
+ }
+
+ blockquote {
+ border-left: 3px solid #d0d7de;
+ margin: 0.6em 0;
+ padding-left: 1em;
+ color: #57606a;
+ }
+`;
+
+export const SummaryViewer = ({ markdown }: SummaryViewerProps) => {
+ if (!isDefined(markdown) || markdown.trim().length === 0) {
+ return;
+ }
+
+ return (
+
+
+ {markdown}
+
+
+ );
+};
diff --git a/packages/twenty-apps/internal/call-recording/src/components/SummaryViewerSkeleton.tsx b/packages/twenty-apps/internal/call-recording/src/components/SummaryViewerSkeleton.tsx
new file mode 100644
index 0000000000..7c05678775
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/src/components/SummaryViewerSkeleton.tsx
@@ -0,0 +1,36 @@
+import styled from '@emotion/styled';
+import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
+
+import {
+ SKELETON_BASE_COLOR,
+ SKELETON_HIGHLIGHT_COLOR,
+ StyledSummarySkeletonContainer,
+} from 'src/constants/skeleton-constants';
+
+const StyledSkeletonCard = styled.div`
+ background: #ffffff;
+ border-radius: 12px;
+ border: 1px solid rgba(0, 0, 0, 0.06);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
+ overflow: hidden;
+`;
+
+export const SummaryViewerSkeleton = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/twenty-apps/internal/call-recording/src/components/TranscriptViewer.tsx b/packages/twenty-apps/internal/call-recording/src/components/TranscriptViewer.tsx
new file mode 100644
index 0000000000..b59095dc7a
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/src/components/TranscriptViewer.tsx
@@ -0,0 +1,136 @@
+import styled from '@emotion/styled';
+
+import {
+ type TranscriptEntry,
+ type TranscriptWord,
+} from 'src/hooks/useTranscript';
+import { isDefined } from 'twenty-shared/utils';
+
+type TranscriptViewerProps = {
+ entries: TranscriptEntry[];
+ currentTimeSeconds: number;
+};
+
+const StyledTranscriptCard = styled.div`
+ background: #ffffff;
+ border-radius: 8px;
+ overflow: hidden;
+`;
+
+const StyledTranscriptContent = styled.div`
+ max-height: 500px;
+ overflow-y: auto;
+`;
+
+const StyledEntry = styled.div<{ isActive: boolean }>`
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 8px 12px;
+ border-radius: 4px;
+ transition: background-color 0.2s ease;
+ background-color: ${({ isActive }) =>
+ isActive ? '#f1f1f1' : 'transparent'};
+
+ & + & {
+ margin-top: 2px;
+ }
+`;
+
+const StyledSpeaker = styled.span`
+ font-weight: 600;
+ color: #333333;
+ font-size: 0.92rem;
+`;
+
+const StyledTextContent = styled.div`
+ line-height: 1.5;
+`;
+
+const StyledWord = styled.span<{ isSpoken: boolean }>`
+ font-size: 0.92rem;
+ line-height: 1.5;
+ transition: color 0.15s ease;
+ color: ${({ isSpoken }) => (isSpoken ? '#333333' : '#b3b3b3')};
+`;
+
+const getEntryTimeRange = (entry: TranscriptEntry) => {
+ const firstWord = entry.words[0];
+ const lastWord = entry.words[entry.words.length - 1];
+
+ const start = firstWord?.start_timestamp?.relative;
+ const end = lastWord?.end_timestamp?.relative;
+
+ return { start, end };
+};
+
+const findActiveEntryIndex = (
+ entries: TranscriptEntry[],
+ currentTimeSeconds: number,
+): number => {
+ for (let index = entries.length - 1; index >= 0; index--) {
+ const { start, end } = getEntryTimeRange(entries[index]);
+
+ if (!isDefined(start) || !isDefined(end)) {
+ continue;
+ }
+
+ if (currentTimeSeconds >= start && currentTimeSeconds <= end) {
+ return index;
+ }
+ }
+
+ return -1;
+};
+
+const isWordSpoken = (
+ word: TranscriptWord,
+ currentTimeSeconds: number,
+): boolean => {
+ const start = word.start_timestamp?.relative;
+
+ if (!isDefined(start)) {
+ return false;
+ }
+
+ return currentTimeSeconds >= start;
+};
+
+export const TranscriptViewer = ({
+ entries,
+ currentTimeSeconds,
+}: TranscriptViewerProps) => {
+ if (entries.length === 0) {
+ return;
+ }
+
+ const activeEntryIndex = findActiveEntryIndex(entries, currentTimeSeconds);
+
+ return (
+
+
+ {entries.map((entry, index) => {
+ const speaker = entry.participant?.name ?? 'Unknown';
+ const isActive = index === activeEntryIndex;
+
+ return (
+
+ {speaker}
+
+ {entry.words.map((word, wordIndex) => (
+
+ {wordIndex > 0 ? ' ' : ''}
+ {word.text}
+
+ ))}
+
+
+ );
+ })}
+
+
+ );
+};
diff --git a/packages/twenty-apps/internal/call-recording/src/components/VideoPlayer.tsx b/packages/twenty-apps/internal/call-recording/src/components/VideoPlayer.tsx
new file mode 100644
index 0000000000..c7142d8cd6
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/src/components/VideoPlayer.tsx
@@ -0,0 +1,46 @@
+import styled from '@emotion/styled';
+
+import { SerializedEventData } from 'twenty-sdk/dist/sdk/front-component-api';
+
+const StyledVideoWrapper = styled.div`
+ border-radius: 12px;
+ overflow: hidden;
+ border: 1px solid rgba(0, 0, 0, 0.06);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
+ background: #000;
+`;
+
+const StyledVideo = styled.video`
+ width: 100%;
+ display: block;
+`;
+
+type VideoPlayerProps = {
+ src: string;
+ extension: string;
+ onTimeUpdate?: (currentTimeSeconds: number) => void;
+};
+
+export const VideoPlayer = ({
+ src,
+ extension,
+ onTimeUpdate,
+}: VideoPlayerProps) => {
+ return (
+
+ {
+ const currentTime = (event as CustomEvent)
+ .detail.currentTime;
+
+ if (typeof currentTime === 'number') {
+ onTimeUpdate?.(currentTime);
+ }
+ }}
+ >
+
+
+
+ );
+};
diff --git a/packages/twenty-apps/internal/call-recording/src/constants/audio-extensions.ts b/packages/twenty-apps/internal/call-recording/src/constants/audio-extensions.ts
new file mode 100644
index 0000000000..e3a3139e8e
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/src/constants/audio-extensions.ts
@@ -0,0 +1,8 @@
+export const AUDIO_EXTENSIONS = [
+ 'mp3',
+ 'wav',
+ 'ogg',
+ 'aac',
+ 'flac',
+ 'webm',
+] as const;
diff --git a/packages/twenty-apps/internal/call-recording/src/constants/call-recording-summary-viewer-front-component-universal-identifier.ts b/packages/twenty-apps/internal/call-recording/src/constants/call-recording-summary-viewer-front-component-universal-identifier.ts
new file mode 100644
index 0000000000..89f57ceb6a
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/src/constants/call-recording-summary-viewer-front-component-universal-identifier.ts
@@ -0,0 +1,2 @@
+export const CALL_RECORDING_SUMMARY_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
+ '43aae2d4-396a-4c5e-9f45-0162a2904825';
diff --git a/packages/twenty-apps/internal/call-recording/src/constants/call-recording-viewer-front-component-universal-identifier.ts b/packages/twenty-apps/internal/call-recording/src/constants/call-recording-viewer-front-component-universal-identifier.ts
new file mode 100644
index 0000000000..9553f02edd
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/src/constants/call-recording-viewer-front-component-universal-identifier.ts
@@ -0,0 +1,2 @@
+export const CALL_RECORDING_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
+ '9f3bbb39-042d-4216-b8fc-bedfc3487208';
diff --git a/packages/twenty-apps/internal/call-recording/src/constants/seed-call-recordings-universal-identifiers.ts b/packages/twenty-apps/internal/call-recording/src/constants/seed-call-recordings-universal-identifiers.ts
new file mode 100644
index 0000000000..37546cb36a
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/src/constants/seed-call-recordings-universal-identifiers.ts
@@ -0,0 +1,5 @@
+export const SEED_CALL_RECORDINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
+ '0894353c-86d2-484c-84f2-802cf5c4d22b';
+
+export const SEED_CALL_RECORDINGS_COMMAND_UNIVERSAL_IDENTIFIER =
+ 'e0e5b7a1-d472-4897-88d0-ce5f1bf6a5ea';
diff --git a/packages/twenty-apps/internal/call-recording/src/constants/skeleton-constants.ts b/packages/twenty-apps/internal/call-recording/src/constants/skeleton-constants.ts
new file mode 100644
index 0000000000..181cd8842f
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/src/constants/skeleton-constants.ts
@@ -0,0 +1,24 @@
+import styled from '@emotion/styled';
+
+export const SKELETON_BASE_COLOR = '#f0f1f3';
+export const SKELETON_HIGHLIGHT_COLOR = '#f8f9fb';
+
+export const StyledSummarySkeletonContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 20px 24px;
+ width: 100%;
+ box-sizing: border-box;
+`;
+
+export const StyledViewerSkeletonContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ padding: 20px;
+ max-width: 960px;
+ margin: 0 auto;
+ width: 100%;
+ box-sizing: border-box;
+`;
diff --git a/packages/twenty-apps/internal/call-recording/src/constants/summarize-person-recordings-universal-identifiers.ts b/packages/twenty-apps/internal/call-recording/src/constants/summarize-person-recordings-universal-identifiers.ts
new file mode 100644
index 0000000000..07accf0cb1
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/src/constants/summarize-person-recordings-universal-identifiers.ts
@@ -0,0 +1,5 @@
+export const SUMMARIZE_PERSON_RECORDINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
+ '958c796e-baf0-472c-838f-8d0a7f572774';
+
+export const SUMMARIZE_PERSON_RECORDINGS_COMMAND_UNIVERSAL_IDENTIFIER =
+ '30e73886-18e7-476f-9c55-b19c59397a81';
diff --git a/packages/twenty-apps/internal/call-recording/src/constants/video-extensions.ts b/packages/twenty-apps/internal/call-recording/src/constants/video-extensions.ts
new file mode 100644
index 0000000000..463ff92c11
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/src/constants/video-extensions.ts
@@ -0,0 +1,8 @@
+export const VIDEO_EXTENSIONS = [
+ 'mp4',
+ 'webm',
+ 'ogv',
+ 'avi',
+ 'mov',
+ 'mkv',
+] as const;
diff --git a/packages/twenty-apps/internal/call-recording/src/data/mock-call-recordings.ts b/packages/twenty-apps/internal/call-recording/src/data/mock-call-recordings.ts
new file mode 100644
index 0000000000..c365fbf068
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/src/data/mock-call-recordings.ts
@@ -0,0 +1,574 @@
+export type MockCallRecording = {
+ name: string;
+ createdAt: string;
+ endedAt: string;
+ status: 'ENDED';
+ transcript: { blocknote: null; markdown: string };
+ summary: { blocknote: null; markdown: string };
+};
+
+export const MOCK_CALL_RECORDINGS: MockCallRecording[] = [
+ {
+ name: 'Call Sarah Chen / Mike Johnson',
+ createdAt: '2025-01-08T10:00:00.000Z',
+ endedAt: '2025-01-08T10:32:00.000Z',
+ status: 'ENDED',
+ transcript: {
+ blocknote: null,
+ markdown: [
+ '**Mike Johnson:** Hi Sarah, thanks for taking the time today. I wanted to walk you through how we handle pipeline management and see if there might be a fit for your team.',
+ '**Sarah Chen:** Of course, Mike. We\'ve been looking at several CRM options. Right now we\'re using spreadsheets and it\'s becoming unmanageable with the team growing.',
+ '**Mike Johnson:** That\'s a common pain point. How many reps are on your team currently?',
+ '**Sarah Chen:** We have twelve sales reps and three managers. The biggest issue is visibility. Managers can\'t see deal progress in real time, and reps are spending too much time on data entry.',
+ '**Mike Johnson:** I hear that a lot. One thing that sets us apart is our approach to reducing manual entry. We automatically capture emails, meeting notes, and even call data. Would that address your main concern?',
+ '**Sarah Chen:** That would be huge. Our reps probably spend an hour a day just logging activities. What about reporting? We need weekly pipeline reviews with accurate forecasting.',
+ '**Mike Johnson:** Absolutely. I can set up a demo environment for your team. We have built-in forecasting that uses historical win rates and deal velocity. Should we schedule a more in-depth demo with your managers next week?',
+ '**Sarah Chen:** Yes, let\'s do that. Tuesday or Wednesday afternoon would work best for us.',
+ ].join('\n\n'),
+ },
+ summary: {
+ blocknote: null,
+ markdown: [
+ '## Overview',
+ 'Initial discovery call with Sarah Chen (VP Sales at prospect company) to assess CRM needs and pipeline management requirements.',
+ '',
+ '## Key Discussion Points',
+ '- Current setup: spreadsheets, 12 reps + 3 managers',
+ '- Main pain points: lack of real-time visibility, excessive manual data entry (~1 hour/day per rep)',
+ '- Interest in automated activity capture (emails, meetings, calls)',
+ '- Need for weekly pipeline reporting and accurate forecasting',
+ '',
+ '## Action Items',
+ '- [ ] Schedule in-depth demo with Sarah\'s managers for Tuesday or Wednesday afternoon',
+ '- [ ] Prepare demo environment with pipeline forecasting features',
+ '- [ ] Send follow-up email with product overview deck',
+ ].join('\n'),
+ },
+ },
+ {
+ name: 'Call David Park / Emily Rivera',
+ createdAt: '2025-01-22T14:30:00.000Z',
+ endedAt: '2025-01-22T15:05:00.000Z',
+ status: 'ENDED',
+ transcript: {
+ blocknote: null,
+ markdown: [
+ '**Emily Rivera:** David, good to connect again. I wanted to follow up on the demo we did last week. How did the team feel about it?',
+ '**David Park:** The feedback was really positive overall. The interface is clean and the automation features impressed everyone. A couple of questions came up though.',
+ '**Emily Rivera:** Great to hear! What questions did the team have?',
+ '**David Park:** First, our legal team wants to know about data residency. We need our data hosted in the EU. Second, we\'re wondering about the integration with Salesforce — we still have some legacy data there.',
+ '**Emily Rivera:** Both great questions. We offer EU data residency with our Business plan. For Salesforce, we have a native migration tool that can import your historical data including contacts, deals, and activities. It typically takes about a day for a dataset your size.',
+ '**David Park:** That\'s reassuring. And what about pricing for our team size? We\'d be looking at about forty users.',
+ '**Emily Rivera:** For forty users on the Business plan with EU residency, I can put together a custom proposal. We also offer annual billing discounts. Let me send that over by Friday.',
+ '**David Park:** Perfect. If the pricing works, I think we\'re ready to move forward. We\'d want to start onboarding in March.',
+ ].join('\n\n'),
+ },
+ summary: {
+ blocknote: null,
+ markdown: [
+ '## Overview',
+ 'Follow-up call with David Park post-demo to address team feedback and move toward closing.',
+ '',
+ '## Key Discussion Points',
+ '- Demo feedback was positive across the team',
+ '- EU data residency requirement — available on Business plan',
+ '- Salesforce migration needed for legacy data (contacts, deals, activities)',
+ '- Team size: ~40 users',
+ '- Target onboarding start: March',
+ '',
+ '## Action Items',
+ '- [ ] Send custom pricing proposal for 40 users on Business plan by Friday',
+ '- [ ] Include EU data residency details and Salesforce migration timeline',
+ '- [ ] Prepare onboarding plan for March start',
+ ].join('\n'),
+ },
+ },
+ {
+ name: 'Call Lisa Wong / James Martinez',
+ createdAt: '2025-02-05T09:00:00.000Z',
+ endedAt: '2025-02-05T09:28:00.000Z',
+ status: 'ENDED',
+ transcript: {
+ blocknote: null,
+ markdown: [
+ '**James Martinez:** Lisa, thanks for joining. This is your first quarterly review since onboarding. How has the first month been going?',
+ '**Lisa Wong:** It\'s been great honestly. The team adopted it much faster than I expected. We\'re already seeing about a thirty percent reduction in time spent on data entry.',
+ '**James Martinez:** That\'s fantastic. Are there any areas where you feel we could improve the experience?',
+ '**Lisa Wong:** One thing that came up is custom fields. We have some industry-specific data points we track — like compliance status and license numbers — and we\'d like to add those as fields on our contact records.',
+ '**James Martinez:** You can absolutely do that. I\'ll send you a guide on creating custom fields. You can add text, dropdown, date, or number fields to any object. Any other requests?',
+ '**Lisa Wong:** Our marketing team is asking about the API. They want to push lead data from our website forms directly into the CRM.',
+ '**James Martinez:** Our REST API and GraphQL API both support that. I\'ll connect you with our developer relations team for a quick walkthrough. They can have your marketing team set up in about an hour.',
+ ].join('\n\n'),
+ },
+ summary: {
+ blocknote: null,
+ markdown: [
+ '## Overview',
+ 'First quarterly review with Lisa Wong, one month post-onboarding. Team adoption is strong with measurable productivity gains.',
+ '',
+ '## Key Discussion Points',
+ '- 30% reduction in data entry time reported',
+ '- Need for custom fields: compliance status, license numbers',
+ '- Marketing team interest in API integration for website lead forms',
+ '',
+ '## Action Items',
+ '- [ ] Send custom fields documentation to Lisa',
+ '- [ ] Connect Lisa\'s marketing team with developer relations for API walkthrough',
+ '- [ ] Schedule next quarterly review for May',
+ ].join('\n'),
+ },
+ },
+ {
+ name: 'Call Robert Taylor / Anna Schmidt',
+ createdAt: '2025-02-19T16:00:00.000Z',
+ endedAt: '2025-02-19T16:45:00.000Z',
+ status: 'ENDED',
+ transcript: {
+ blocknote: null,
+ markdown: [
+ '**Anna Schmidt:** Robert, I appreciate you making time. I wanted to understand your current sales process before we put together a proposal.',
+ '**Robert Taylor:** Sure. We\'re a B2B SaaS company, about two hundred employees. Our sales cycle is typically three to six months. We have SDRs doing outbound, AEs closing, and a small customer success team.',
+ '**Anna Schmidt:** What tools are you using today across those teams?',
+ '**Robert Taylor:** It\'s a mess honestly. SDRs use one tool for outreach, AEs use a different CRM, and customer success has their own platform. Nothing talks to each other.',
+ '**Anna Schmidt:** That fragmentation is really common. How does it impact your day-to-day?',
+ '**Robert Taylor:** The biggest issue is handoffs. When an SDR qualifies a lead and passes it to an AE, context gets lost. Same thing when a deal closes and moves to customer success. We lose notes, meeting history, everything.',
+ '**Anna Schmidt:** That\'s exactly what we solve. A single platform for the entire customer lifecycle — from first touch through renewal. All the context travels with the record. Let me show you a quick overview of how that handoff looks in practice.',
+ '**Robert Taylor:** That would be great. And can you also show me how your workflow automation works? We want to automate some of our internal notifications and task assignments.',
+ ].join('\n\n'),
+ },
+ summary: {
+ blocknote: null,
+ markdown: [
+ '## Overview',
+ 'Discovery call with Robert Taylor (B2B SaaS, ~200 employees) to map current sales process and identify pain points.',
+ '',
+ '## Key Discussion Points',
+ '- Sales cycle: 3-6 months with SDR → AE → CS handoffs',
+ '- Tool fragmentation: separate tools for outreach, CRM, and customer success',
+ '- Critical pain: context loss during handoffs (notes, meeting history)',
+ '- Interest in workflow automation for notifications and task assignments',
+ '',
+ '## Action Items',
+ '- [ ] Prepare demo focused on lifecycle handoff workflows',
+ '- [ ] Include workflow automation examples for internal notifications',
+ '- [ ] Schedule demo for next week',
+ ].join('\n'),
+ },
+ },
+ {
+ name: 'Call Priya Patel / Tom Wilson',
+ createdAt: '2025-03-12T11:00:00.000Z',
+ endedAt: '2025-03-12T11:38:00.000Z',
+ status: 'ENDED',
+ transcript: {
+ blocknote: null,
+ markdown: [
+ '**Tom Wilson:** Hi Priya, I wanted to touch base about the pricing proposal we sent over. Have you had a chance to review it with your CFO?',
+ '**Priya Patel:** Yes, we went through it yesterday. The per-seat pricing is within our budget, but we\'re concerned about the implementation cost. Fifty thousand for onboarding feels steep.',
+ '**Tom Wilson:** I understand. That fee covers dedicated onboarding support, data migration from your three existing systems, custom training sessions, and sixty days of post-launch support. What would make it work for your budget?',
+ '**Priya Patel:** If we could break the implementation into two phases, that would help. Phase one would be the core sales team migration, and phase two would be marketing and customer success. That way we spread the cost over two quarters.',
+ '**Tom Wilson:** We can absolutely structure it that way. In fact, phased rollouts often lead to better adoption. We could do phase one for thirty thousand and phase two for twenty-five, with phase two starting three months later.',
+ '**Priya Patel:** That works much better. I think we can get sign-off on that. Can you send a revised proposal with those terms?',
+ '**Tom Wilson:** I\'ll have it in your inbox by end of day. If everything looks good, we could target April first for kickoff.',
+ '**Priya Patel:** Let\'s plan on that. I\'ll schedule an internal alignment meeting for Friday to finalize.',
+ ].join('\n\n'),
+ },
+ summary: {
+ blocknote: null,
+ markdown: [
+ '## Overview',
+ 'Pricing negotiation call with Priya Patel. Agreed on a phased implementation to fit budget constraints.',
+ '',
+ '## Key Discussion Points',
+ '- Per-seat pricing approved, implementation cost ($50K) was a concern',
+ '- Agreed on phased approach: Phase 1 ($30K, core sales) + Phase 2 ($25K, marketing & CS)',
+ '- Phase 2 starts 3 months after Phase 1',
+ '- Target kickoff: April 1st',
+ '',
+ '## Action Items',
+ '- [ ] Send revised proposal with phased implementation terms by end of day',
+ '- [ ] Prepare Phase 1 kickoff plan targeting April 1st',
+ '- [ ] Priya to schedule internal alignment meeting for Friday',
+ ].join('\n'),
+ },
+ },
+ {
+ name: 'Call Marcus Lee / Sophie Dubois',
+ createdAt: '2025-04-03T13:00:00.000Z',
+ endedAt: '2025-04-03T13:22:00.000Z',
+ status: 'ENDED',
+ transcript: {
+ blocknote: null,
+ markdown: [
+ '**Sophie Dubois:** Marcus, welcome to your onboarding kickoff. I\'m your dedicated customer success manager and I\'ll be guiding you through the setup process over the next four weeks.',
+ '**Marcus Lee:** Great, the team is excited to get started. We have twenty users ready to go on day one.',
+ '**Sophie Dubois:** Perfect. Let me walk you through the onboarding timeline. Week one is data migration and system configuration. Week two is user training. Week three is a guided pilot where your team uses it in parallel with your old system. Week four is full cutover and go-live.',
+ '**Marcus Lee:** That sounds structured. For the data migration, we have about fifty thousand contacts and ten thousand deals in our current system. Is that going to be an issue?',
+ '**Sophie Dubois:** Not at all. That\'s well within our standard migration capacity. I\'ll need your team to export the data in CSV format and I\'ll handle the mapping and import. We typically complete migrations of that size in two to three days.',
+ '**Marcus Lee:** And what about our custom deal stages? We have a pretty specific pipeline with eight stages.',
+ '**Sophie Dubois:** We\'ll configure that during week one. You can have as many stages as you need, and we can set up automation rules for each transition. I\'ll send you a configuration worksheet to fill out before our next session.',
+ ].join('\n\n'),
+ },
+ summary: {
+ blocknote: null,
+ markdown: [
+ '## Overview',
+ 'Onboarding kickoff with Marcus Lee. Covered the 4-week timeline and initial setup requirements.',
+ '',
+ '## Key Discussion Points',
+ '- 20 users ready for day one',
+ '- Data migration: ~50K contacts, ~10K deals (CSV export needed)',
+ '- Custom pipeline: 8 deal stages with automation rules',
+ '- 4-week onboarding plan: migration → training → pilot → go-live',
+ '',
+ '## Action Items',
+ '- [ ] Send configuration worksheet for custom pipeline stages',
+ '- [ ] Marcus to prepare CSV exports of contacts and deals',
+ '- [ ] Schedule week 1 check-in for data migration review',
+ ].join('\n'),
+ },
+ },
+ {
+ name: 'Call Jennifer Brooks / Carlos Mendez',
+ createdAt: '2025-05-14T15:30:00.000Z',
+ endedAt: '2025-05-14T16:02:00.000Z',
+ status: 'ENDED',
+ transcript: {
+ blocknote: null,
+ markdown: [
+ '**Carlos Mendez:** Jennifer, thanks for the demo request. I see you\'re interested in our analytics and reporting capabilities. Can you tell me what you\'re looking for specifically?',
+ '**Jennifer Brooks:** We need better visibility into rep performance. Right now I\'m pulling data from three different sources to build my weekly report. It takes me half a day every Monday.',
+ '**Carlos Mendez:** That\'s painful. What metrics do you track in those reports?',
+ '**Jennifer Brooks:** Calls made, emails sent, meetings booked, pipeline generated, deals closed, and average deal size. I also need to compare rep-to-rep and track trends over time.',
+ '**Carlos Mendez:** All of those are available out of the box in our analytics dashboard. Let me share my screen and show you. Here you can see a real-time dashboard with all those metrics. You can filter by rep, team, date range, and even deal stage.',
+ '**Jennifer Brooks:** Oh wow, that\'s exactly what I need. Can I schedule these reports to be sent automatically?',
+ '**Carlos Mendez:** Yes, you can set up scheduled email reports — daily, weekly, or monthly. You can also create custom dashboards and share them with your team or leadership. Each person only sees data they have access to.',
+ '**Jennifer Brooks:** This would save me so much time. What does pricing look like for a team of twenty-five?',
+ ].join('\n\n'),
+ },
+ summary: {
+ blocknote: null,
+ markdown: [
+ '## Overview',
+ 'Demo call with Jennifer Brooks focused on analytics and reporting capabilities. Strong interest in automated reporting.',
+ '',
+ '## Key Discussion Points',
+ '- Current report building takes half a day weekly across 3 data sources',
+ '- Key metrics: calls, emails, meetings, pipeline, deals closed, avg deal size',
+ '- Rep-to-rep comparison and trend tracking required',
+ '- Demonstrated real-time dashboard, scheduled reports, and custom dashboards',
+ '',
+ '## Action Items',
+ '- [ ] Send pricing proposal for 25-user team',
+ '- [ ] Share sample analytics dashboard templates',
+ '- [ ] Schedule follow-up to review proposal with Jennifer\'s leadership',
+ ].join('\n'),
+ },
+ },
+ {
+ name: 'Call Alex Nguyen / Rachel Foster',
+ createdAt: '2025-06-20T10:00:00.000Z',
+ endedAt: '2025-06-20T10:41:00.000Z',
+ status: 'ENDED',
+ transcript: {
+ blocknote: null,
+ markdown: [
+ '**Rachel Foster:** Alex, this is your six-month review. Let\'s go over the numbers. How are things going since you fully migrated in January?',
+ '**Alex Nguyen:** Really well. Our sales cycle has shortened by about twenty percent. Reps are closing deals faster because they have all the context in one place.',
+ '**Rachel Foster:** That\'s a significant improvement. What about adoption? Are all teams using the platform regularly?',
+ '**Alex Nguyen:** The sales team is fully on board, probably ninety-five percent daily usage. Marketing is at about eighty percent. The one area where we\'re struggling is getting our field sales team to use the mobile app consistently.',
+ '**Rachel Foster:** Mobile adoption is often the trickiest. We recently launched offline mode which helps a lot for field reps with spotty connectivity. I can set up a quick training session specifically for your field team.',
+ '**Alex Nguyen:** That would be great. Also, we\'re looking at expanding to our APAC team next quarter. That would add about thirty more users. What does that look like from a licensing perspective?',
+ '**Rachel Foster:** I\'ll work with your account executive to prepare an expansion quote. We can usually offer volume discounts when scaling beyond fifty users. I\'ll have that ready for your budget planning meeting.',
+ '**Alex Nguyen:** Perfect timing. Our planning cycle starts in August so if we can have numbers by mid-July that would be ideal.',
+ ].join('\n\n'),
+ },
+ summary: {
+ blocknote: null,
+ markdown: [
+ '## Overview',
+ 'Six-month review with Alex Nguyen. Strong results with 20% shorter sales cycles. Planning APAC expansion.',
+ '',
+ '## Key Discussion Points',
+ '- Sales cycle reduced by 20% since migration',
+ '- Adoption: 95% daily (sales), 80% (marketing), field team needs mobile training',
+ '- New offline mode could help field sales adoption',
+ '- APAC expansion planned: +30 users next quarter',
+ '',
+ '## Action Items',
+ '- [ ] Schedule mobile app training for field sales team',
+ '- [ ] Prepare APAC expansion quote with volume discounts by mid-July',
+ '- [ ] Connect with account executive on expansion pricing',
+ ].join('\n'),
+ },
+ },
+ {
+ name: 'Call Kevin O\'Brien / Maria Santos',
+ createdAt: '2025-07-09T14:00:00.000Z',
+ endedAt: '2025-07-09T14:35:00.000Z',
+ status: 'ENDED',
+ transcript: {
+ blocknote: null,
+ markdown: [
+ '**Maria Santos:** Kevin, I understand your team is evaluating us alongside two other CRM vendors. I\'d love to understand what criteria are most important to you.',
+ '**Kevin O\'Brien:** Right, we\'re in the final stages of evaluation. The three biggest factors for us are ease of use, integration ecosystem, and total cost of ownership over three years.',
+ '**Maria Santos:** Makes sense. On ease of use, our average onboarding time is two weeks and we consistently score highest in user satisfaction surveys. What integrations are most critical for you?',
+ '**Kevin O\'Brien:** We need Slack, Google Workspace, Zoom, and our billing system which runs on Stripe. We also use Notion for internal documentation.',
+ '**Maria Santos:** All of those have native integrations except Notion, which we support through our Zapier and Make connectors. For Stripe, our integration syncs billing data bidirectionally so your sales team can see payment status directly on the deal record.',
+ '**Kevin O\'Brien:** The Stripe integration is a big differentiator actually. The other vendors we\'re looking at don\'t offer that natively. What about the three-year cost comparison?',
+ '**Maria Santos:** I\'ll put together a total cost of ownership analysis that includes licensing, implementation, training, and ongoing support. We\'re typically fifteen to twenty percent lower than our main competitors when you factor in everything.',
+ '**Kevin O\'Brien:** Send that over and I\'ll present it to our executive team next Thursday. We\'re planning to make a decision by end of month.',
+ ].join('\n\n'),
+ },
+ summary: {
+ blocknote: null,
+ markdown: [
+ '## Overview',
+ 'Competitive evaluation call with Kevin O\'Brien. In final stages, comparing against two other vendors.',
+ '',
+ '## Key Discussion Points',
+ '- Evaluation criteria: ease of use, integrations, 3-year TCO',
+ '- Required integrations: Slack, Google Workspace, Zoom, Stripe (native), Notion (via Zapier/Make)',
+ '- Stripe bidirectional sync is a key differentiator vs competitors',
+ '- Decision timeline: end of month, executive presentation next Thursday',
+ '',
+ '## Action Items',
+ '- [ ] Prepare 3-year TCO analysis vs competitors',
+ '- [ ] Send integration ecosystem overview document',
+ '- [ ] Follow up before Thursday executive presentation',
+ ].join('\n'),
+ },
+ },
+ {
+ name: 'Call Diana Hughes / Ryan Cooper',
+ createdAt: '2025-08-18T09:30:00.000Z',
+ endedAt: '2025-08-18T10:05:00.000Z',
+ status: 'ENDED',
+ transcript: {
+ blocknote: null,
+ markdown: [
+ '**Ryan Cooper:** Diana, I noticed some of your team\'s usage metrics dropped last month. Everything okay?',
+ '**Diana Hughes:** Honestly, we\'ve been struggling with a few things. The email sync stopped working for about half our team two weeks ago and we haven\'t been able to figure out why.',
+ '**Ryan Cooper:** I\'m sorry to hear that. Let me look into this right now. Can you tell me which email provider you\'re using?',
+ '**Diana Hughes:** We\'re on Microsoft 365. It was working fine and then suddenly half the team\'s emails stopped syncing. The other half is still fine.',
+ '**Ryan Cooper:** I think I see the issue. Microsoft recently changed their OAuth token refresh policy. The affected users likely need to re-authenticate. I\'ll send you a step-by-step guide. It should take each user about two minutes.',
+ '**Diana Hughes:** Okay that\'s a relief, I was worried it was something bigger. The other thing is we need better support response times. We submitted a ticket about this five days ago and didn\'t hear back until yesterday.',
+ '**Ryan Cooper:** That\'s not acceptable and I apologize. I\'m escalating this with our support team. For your account, I\'m also going to set up a dedicated Slack channel so you can reach me or someone on my team directly for urgent issues.',
+ '**Diana Hughes:** That would make a huge difference. We need to be able to get help quickly when something breaks.',
+ ].join('\n\n'),
+ },
+ summary: {
+ blocknote: null,
+ markdown: [
+ '## Overview',
+ 'Issue resolution call with Diana Hughes regarding email sync failures and support response times.',
+ '',
+ '## Key Discussion Points',
+ '- Email sync broken for ~50% of team (Microsoft 365, OAuth token refresh issue)',
+ '- Support ticket response took 5 days — unacceptable',
+ '- Setting up dedicated Slack channel for urgent issues',
+ '',
+ '## Risks',
+ '- Customer satisfaction at risk due to slow support response',
+ '- Usage metrics declining — need to restore confidence quickly',
+ '',
+ '## Action Items',
+ '- [ ] Send OAuth re-authentication guide to Diana immediately',
+ '- [ ] Escalate support response time issue internally',
+ '- [ ] Set up dedicated Slack channel for Diana\'s team',
+ '- [ ] Follow up in 48 hours to confirm email sync is restored',
+ ].join('\n'),
+ },
+ },
+ {
+ name: 'Call Nathan Kim / Laura Chen / Steve Morris',
+ createdAt: '2025-09-25T16:00:00.000Z',
+ endedAt: '2025-09-25T16:42:00.000Z',
+ status: 'ENDED',
+ transcript: {
+ blocknote: null,
+ markdown: [
+ '**Steve Morris:** Alright team, let\'s do our monthly pipeline review. Nathan, kick us off with the numbers.',
+ '**Nathan Kim:** Sure. Total pipeline is at two point four million, up twelve percent from last month. We have eight deals in the negotiation stage totaling about nine hundred K. Three of those should close this month.',
+ '**Laura Chen:** Which three are you most confident about?',
+ '**Nathan Kim:** Meridian Corp at three hundred and twenty K — contract is out for signature. TechFlow at two hundred and ten K — verbal agreement, just working through procurement. And BrightPath at one hundred and fifty K — they want to start before their fiscal year end on October fifteenth.',
+ '**Steve Morris:** Good. What about the other five? Any at risk?',
+ '**Nathan Kim:** Two are solid but won\'t close until November. The other three I\'m worried about. DataSync keeps pushing their timeline back and I think they might be evaluating a competitor.',
+ '**Laura Chen:** Let me jump on a call with the DataSync champion this week. I have a relationship there from a previous company. Maybe I can help move things forward.',
+ '**Steve Morris:** Great idea. Nathan, can you also do a deep dive on our Q4 pipeline generation? We need to make sure we\'re building enough top-of-funnel to hit our annual target.',
+ ].join('\n\n'),
+ },
+ summary: {
+ blocknote: null,
+ markdown: [
+ '## Overview',
+ 'Internal monthly pipeline review. Pipeline at $2.4M (+12% MoM), 8 deals in negotiation stage.',
+ '',
+ '## Key Discussion Points',
+ '- 3 deals expected to close this month: Meridian ($320K), TechFlow ($210K), BrightPath ($150K)',
+ '- 2 deals solid for November close',
+ '- 3 deals at risk, especially DataSync (may be evaluating competitor)',
+ '- Need to build Q4 top-of-funnel pipeline',
+ '',
+ '## Action Items',
+ '- [ ] Laura to contact DataSync champion this week',
+ '- [ ] Nathan to prepare Q4 pipeline generation analysis',
+ '- [ ] Follow up on Meridian contract signature',
+ '- [ ] Track BrightPath against Oct 15 fiscal year deadline',
+ ].join('\n'),
+ },
+ },
+ {
+ name: 'Call Amanda Price / Ben Watson',
+ createdAt: '2025-10-30T11:00:00.000Z',
+ endedAt: '2025-10-30T11:25:00.000Z',
+ status: 'ENDED',
+ transcript: {
+ blocknote: null,
+ markdown: [
+ '**Ben Watson:** Amanda, thanks for your interest. I understand you\'re a startup looking for your first CRM. Tell me about your team.',
+ '**Amanda Price:** We\'re a Series A startup, fifteen people total. Five of us are in go-to-market roles. We\'ve been tracking everything in Airtable and Google Sheets but we need something purpose-built as we scale.',
+ '**Ben Watson:** What\'s your growth plan? Understanding your trajectory helps me recommend the right package.',
+ '**Amanda Price:** We plan to triple the sales team by end of next year. So we need something that can grow with us without breaking the bank in the early days.',
+ '**Ben Watson:** Our startup program is designed exactly for that. You\'d get our full platform at a seventy percent discount for the first year, then fifty percent off the second year, with a gradual step-up to standard pricing.',
+ '**Amanda Price:** That\'s compelling. What\'s the catch? Do we lose any features on the startup plan?',
+ '**Ben Watson:** No catch — full feature parity. The only difference is the per-seat price. You also get priority onboarding support included. We want to grow with you.',
+ '**Amanda Price:** I love that approach. Can you send me the startup program details? I\'ll review it with my co-founder this week.',
+ ].join('\n\n'),
+ },
+ summary: {
+ blocknote: null,
+ markdown: [
+ '## Overview',
+ 'Discovery call with Amanda Price, Series A startup looking for first CRM. Good fit for startup program.',
+ '',
+ '## Key Discussion Points',
+ '- Series A, 15 people, 5 in GTM roles',
+ '- Currently using Airtable + Google Sheets',
+ '- Plan to 3x sales team by end of next year',
+ '- Startup program: 70% off Y1, 50% off Y2, full features, priority onboarding',
+ '',
+ '## Action Items',
+ '- [ ] Send startup program details and application form',
+ '- [ ] Amanda to review with co-founder this week',
+ '- [ ] Schedule follow-up call for next week',
+ ].join('\n'),
+ },
+ },
+ {
+ name: 'Call Chris Yamamoto / Olivia Barrett',
+ createdAt: '2025-12-03T10:30:00.000Z',
+ endedAt: '2025-12-03T11:08:00.000Z',
+ status: 'ENDED',
+ transcript: {
+ blocknote: null,
+ markdown: [
+ '**Olivia Barrett:** Chris, you\'re coming up on your annual renewal. I wanted to check in and see how things are going before we discuss the renewal terms.',
+ '**Chris Yamamoto:** Overall we\'re happy. The platform has become essential for our team. There are a few enhancements I\'d love to see though.',
+ '**Olivia Barrett:** I\'d love to hear them. What\'s on your wish list?',
+ '**Chris Yamamoto:** First, we really need better territory management. We operate in eight regions and right now we\'re managing territories manually with filters. Second, we\'d like more advanced workflow branching — if-then-else logic in automations.',
+ '**Olivia Barrett:** Good news on both fronts. Territory management is in our Q1 roadmap — we\'re targeting a February launch. Advanced workflow logic is already in beta. I can get your team access to the beta next week if you\'re interested.',
+ '**Chris Yamamoto:** Definitely, sign us up for the beta. For the renewal, we want to add ten more seats. What does that look like pricing-wise?',
+ '**Olivia Barrett:** Adding ten seats at your current rate would bring you to sixty users. Given your expansion and commitment, I can offer a five percent loyalty discount on the full renewal. I\'ll draft the renewal proposal and have it ready by next week.',
+ '**Chris Yamamoto:** That sounds fair. Let\'s aim to have the renewal signed before the holiday break.',
+ ].join('\n\n'),
+ },
+ summary: {
+ blocknote: null,
+ markdown: [
+ '## Overview',
+ 'Annual renewal discussion with Chris Yamamoto. Expanding from 50 to 60 seats with loyalty discount.',
+ '',
+ '## Key Discussion Points',
+ '- Customer is satisfied, platform is essential to operations',
+ '- Feature requests: territory management (in Q1 roadmap), advanced workflow logic (beta available)',
+ '- Expansion: +10 seats (50 → 60 users)',
+ '- 5% loyalty discount offered on full renewal',
+ '- Target: sign renewal before holiday break',
+ '',
+ '## Action Items',
+ '- [ ] Enroll Chris\'s team in workflow logic beta next week',
+ '- [ ] Draft renewal proposal for 60 seats with 5% loyalty discount',
+ '- [ ] Share Q1 territory management roadmap details',
+ '- [ ] Get renewal signed before December holidays',
+ ].join('\n'),
+ },
+ },
+ {
+ name: 'Call Samantha Reed / Derek Chang',
+ createdAt: '2026-01-15T14:00:00.000Z',
+ endedAt: '2026-01-15T14:33:00.000Z',
+ status: 'ENDED',
+ transcript: {
+ blocknote: null,
+ markdown: [
+ '**Derek Chang:** Samantha, your team has been on the platform for about two months now. I wanted to do a health check and see how the implementation is going.',
+ '**Samantha Reed:** The core sales team is doing well. We\'re tracking about ninety percent of our deals in the system now. But I have some concerns about data quality.',
+ '**Derek Chang:** What kind of data quality issues are you seeing?',
+ '**Samantha Reed:** Duplicate contacts mostly. When we imported our data we ended up with a lot of duplicates. And our reps sometimes create new contacts instead of linking to existing ones.',
+ '**Derek Chang:** That\'s a common post-migration issue. We have a built-in deduplication tool that can merge duplicates. I can run an audit on your database this week and present the results. For preventing new duplicates, we can enable duplicate detection rules that alert reps when they\'re about to create a potential duplicate.',
+ '**Samantha Reed:** Yes, please run that audit. The other thing is we\'re not using the email automation features yet. Can we schedule a training session specifically on email sequences?',
+ '**Derek Chang:** Absolutely. I\'ll set up a one-hour training session for your team next week. We\'ll cover sequence creation, personalization tokens, A/B testing, and analytics.',
+ ].join('\n\n'),
+ },
+ summary: {
+ blocknote: null,
+ markdown: [
+ '## Overview',
+ 'Two-month health check with Samantha Reed. Good deal tracking (90%) but data quality concerns with duplicates.',
+ '',
+ '## Key Discussion Points',
+ '- 90% of deals tracked in system — good adoption',
+ '- Duplicate contact issue from data migration and manual creation',
+ '- Built-in deduplication tool and detection rules available',
+ '- Email automation features not yet adopted — training needed',
+ '',
+ '## Action Items',
+ '- [ ] Run duplicate contact audit this week and present results',
+ '- [ ] Enable duplicate detection rules for new contact creation',
+ '- [ ] Schedule 1-hour email sequence training for next week',
+ ].join('\n'),
+ },
+ },
+ {
+ name: 'Call Michelle Torres / Greg Anderson',
+ createdAt: '2026-02-10T09:00:00.000Z',
+ endedAt: '2026-02-10T09:40:00.000Z',
+ status: 'ENDED',
+ transcript: {
+ blocknote: null,
+ markdown: [
+ '**Greg Anderson:** Michelle, I\'m reaching out because we noticed your contract is expiring in thirty days and we haven\'t heard from you about renewal. Is everything alright?',
+ '**Michelle Torres:** To be honest Greg, we\'ve been having some internal discussions about whether to continue. Our new VP of Sales wants to consolidate vendors and he\'s pushing for an all-in-one suite from one of the larger providers.',
+ '**Greg Anderson:** I appreciate the transparency. Can you help me understand what the all-in-one suite offers that we don\'t currently provide?',
+ '**Michelle Torres:** They bundle marketing automation, CRM, and customer service into one platform. The appeal is a single vendor relationship and unified data.',
+ '**Greg Anderson:** I understand the appeal of consolidation. A few things to consider though. You\'d be replacing a tool your team has adopted and loves with something new. Migration costs and ramp-up time are real. And our open API means we integrate deeply with best-of-breed tools for each function. Would it be possible to get fifteen minutes with your VP of Sales? I\'d like to address his concerns directly.',
+ '**Michelle Torres:** I think that\'s fair. Let me check his calendar. If you can make a compelling case for the best-of-breed approach, I think we have a shot at keeping this.',
+ '**Greg Anderson:** I\'ll prepare a comparison analysis showing total cost, migration risk, and feature-by-feature breakdown. I want to make sure your VP has all the data he needs to make the right decision for the team.',
+ ].join('\n\n'),
+ },
+ summary: {
+ blocknote: null,
+ markdown: [
+ '## Overview',
+ 'At-risk renewal call with Michelle Torres. New VP of Sales pushing for vendor consolidation with all-in-one suite.',
+ '',
+ '## Key Discussion Points',
+ '- Contract expires in 30 days, internal debate about renewal',
+ '- New VP wants all-in-one suite (marketing + CRM + service from single vendor)',
+ '- Counter-argument: team adoption, migration costs, best-of-breed approach via API',
+ '- Requested meeting with VP of Sales to present case',
+ '',
+ '## Risks',
+ '- **High churn risk** — decision-maker change driving vendor review',
+ '- Timeline is tight (30 days to contract expiry)',
+ '',
+ '## Action Items',
+ '- [ ] Prepare best-of-breed vs all-in-one comparison analysis',
+ '- [ ] Schedule meeting with Michelle\'s VP of Sales ASAP',
+ '- [ ] Include total cost, migration risk, and feature comparison',
+ '- [ ] Escalate internally as at-risk account',
+ ].join('\n'),
+ },
+ },
+];
diff --git a/packages/twenty-apps/internal/call-recording/src/fields/call-recording-on-person.field.ts b/packages/twenty-apps/internal/call-recording/src/fields/call-recording-on-person.field.ts
new file mode 100644
index 0000000000..ef05bb1aee
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/src/fields/call-recording-on-person.field.ts
@@ -0,0 +1,23 @@
+import {
+ PEOPLE_ON_CALL_RECORDING_ID,
+} from 'src/fields/people-on-call-recording.field';
+import { CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/call-recording';
+import { defineField, FieldType, RelationType } from 'twenty-sdk';
+
+export const CALL_RECORDING_ON_PERSON_ID =
+ 'c62ae064-88aa-48a7-84b3-c9940e3a5db9';
+
+export default defineField({
+ universalIdentifier: CALL_RECORDING_ON_PERSON_ID,
+ objectUniversalIdentifier: '20202020-e674-48e5-a542-72570eee7213',
+ type: FieldType.RELATION,
+ name: 'callRecordings',
+ label: 'Call Recordings',
+ relationTargetObjectMetadataUniversalIdentifier:
+ CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
+ relationTargetFieldMetadataUniversalIdentifier:
+ PEOPLE_ON_CALL_RECORDING_ID,
+ universalSettings: {
+ relationType: RelationType.ONE_TO_MANY,
+ },
+});
diff --git a/packages/twenty-apps/internal/call-recording/src/fields/call-recording-on-workspace-member.field.ts b/packages/twenty-apps/internal/call-recording/src/fields/call-recording-on-workspace-member.field.ts
new file mode 100644
index 0000000000..7e26f0c68b
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/src/fields/call-recording-on-workspace-member.field.ts
@@ -0,0 +1,21 @@
+import { WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID } from 'src/fields/workspace-members-on-call-recording.field';
+import { CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/call-recording';
+import { defineField, FieldType, RelationType } from 'twenty-sdk';
+
+export const CALL_RECORDING_ON_WORKSPACE_MEMBER_ID =
+ 'ae57f5bc-b9f1-4867-887a-834c14737bae';
+
+export default defineField({
+ universalIdentifier: CALL_RECORDING_ON_WORKSPACE_MEMBER_ID,
+ objectUniversalIdentifier: '20202020-3319-4234-a34c-82d5c0e881a6',
+ type: FieldType.RELATION,
+ name: 'callRecordings',
+ label: 'Call Recordings',
+ relationTargetObjectMetadataUniversalIdentifier:
+ CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
+ relationTargetFieldMetadataUniversalIdentifier:
+ WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID,
+ universalSettings: {
+ relationType: RelationType.ONE_TO_MANY,
+ },
+});
diff --git a/packages/twenty-apps/internal/call-recording/src/fields/people-on-call-recording.field.ts b/packages/twenty-apps/internal/call-recording/src/fields/people-on-call-recording.field.ts
new file mode 100644
index 0000000000..ca99cc243f
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/src/fields/people-on-call-recording.field.ts
@@ -0,0 +1,23 @@
+import { CALL_RECORDING_ON_PERSON_ID } from 'src/fields/call-recording-on-person.field';
+import { CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/call-recording';
+import { defineField, FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk';
+
+export const PEOPLE_ON_CALL_RECORDING_ID =
+ '0066e1d2-59f6-4ca7-8073-ca9bd964bfe0';
+
+export default defineField({
+ universalIdentifier: PEOPLE_ON_CALL_RECORDING_ID,
+ objectUniversalIdentifier: CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
+ type: FieldType.RELATION,
+ name: 'person',
+ label: 'Person',
+ relationTargetObjectMetadataUniversalIdentifier:
+ STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
+ relationTargetFieldMetadataUniversalIdentifier:
+ CALL_RECORDING_ON_PERSON_ID,
+ universalSettings: {
+ relationType: RelationType.MANY_TO_ONE,
+ joinColumnName: 'personId',
+ },
+ icon: 'IconUser',
+});
diff --git a/packages/twenty-apps/internal/call-recording/src/fields/workspace-members-on-call-recording.field.ts b/packages/twenty-apps/internal/call-recording/src/fields/workspace-members-on-call-recording.field.ts
new file mode 100644
index 0000000000..bb89d22764
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/src/fields/workspace-members-on-call-recording.field.ts
@@ -0,0 +1,23 @@
+import { CALL_RECORDING_ON_WORKSPACE_MEMBER_ID } from 'src/fields/call-recording-on-workspace-member.field';
+import { CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/call-recording';
+import { defineField, FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk';
+
+export const WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID =
+ '5550e26a-4354-434b-b32e-3f7b04585113';
+
+export default defineField({
+ universalIdentifier: WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID,
+ objectUniversalIdentifier: CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
+ type: FieldType.RELATION,
+ name: 'workspaceMember',
+ label: 'Workspace Member',
+ relationTargetObjectMetadataUniversalIdentifier:
+ STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
+ relationTargetFieldMetadataUniversalIdentifier:
+ CALL_RECORDING_ON_WORKSPACE_MEMBER_ID,
+ universalSettings: {
+ relationType: RelationType.MANY_TO_ONE,
+ joinColumnName: 'workspaceMemberId',
+ },
+ icon: 'IconUser',
+});
diff --git a/packages/twenty-apps/internal/call-recording/src/front-components/call-recording-summary-viewer.tsx b/packages/twenty-apps/internal/call-recording/src/front-components/call-recording-summary-viewer.tsx
new file mode 100644
index 0000000000..2a5b07ed9f
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/src/front-components/call-recording-summary-viewer.tsx
@@ -0,0 +1,28 @@
+import { SummaryViewer } from 'src/components/SummaryViewer';
+import { SummaryViewerSkeleton } from 'src/components/SummaryViewerSkeleton';
+import { CALL_RECORDING_SUMMARY_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-summary-viewer-front-component-universal-identifier';
+import { useCallRecording } from 'src/hooks/useCallRecording';
+import { defineFrontComponent } from 'twenty-sdk';
+import { isDefined } from 'twenty-shared/utils';
+
+const CallRecordingSummaryViewer = () => {
+ const { callRecording, loading, error } = useCallRecording();
+
+ if (loading) {
+ return ;
+ }
+
+ if (isDefined(error)) {
+ throw error;
+ }
+
+ return ;
+};
+
+export default defineFrontComponent({
+ universalIdentifier:
+ CALL_RECORDING_SUMMARY_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
+ name: 'Call Recording Summary Viewer',
+ description: 'Displays the AI-generated summary of a call recording',
+ component: CallRecordingSummaryViewer,
+});
diff --git a/packages/twenty-apps/internal/call-recording/src/front-components/call-recording-viewer.tsx b/packages/twenty-apps/internal/call-recording/src/front-components/call-recording-viewer.tsx
new file mode 100644
index 0000000000..e182f82508
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/src/front-components/call-recording-viewer.tsx
@@ -0,0 +1,78 @@
+import styled from '@emotion/styled';
+import { useState } from 'react';
+
+import { CallRecordingViewerSkeleton } from 'src/components/CallRecordingViewerSkeleton';
+import { MediaPlayer } from 'src/components/MediaPlayer';
+import { SummaryViewerSkeleton } from 'src/components/SummaryViewerSkeleton';
+import { TranscriptViewer } from 'src/components/TranscriptViewer';
+import { CALL_RECORDING_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-viewer-front-component-universal-identifier';
+import { useCallRecording } from 'src/hooks/useCallRecording';
+import { useTranscript } from 'src/hooks/useTranscript';
+import { defineFrontComponent } from 'twenty-sdk';
+import { isDefined } from 'twenty-shared/utils';
+
+const StyledContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ padding: 20px;
+ max-width: 960px;
+ margin: 0 auto;
+ width: 100%;
+ box-sizing: border-box;
+`;
+
+export const CallRecordingViewer = () => {
+ const [currentTimeSeconds, setCurrentTimeSeconds] = useState(0);
+
+ const { callRecording, loading, error } = useCallRecording();
+
+ const transcriptFileUrl = callRecording?.transcriptFile[0]?.url;
+
+ const { entries: transcriptEntries, loading: transcriptLoading } =
+ useTranscript(transcriptFileUrl);
+
+ if (loading) {
+ return ;
+ }
+
+ if (isDefined(error)) {
+ throw error;
+ }
+
+ const recordingFile = callRecording?.recordingFile[0];
+
+ const recordingFileUrl = recordingFile?.url;
+ const recordingFileExtension = recordingFile?.extension;
+
+ const hasRecording =
+ isDefined(recordingFileUrl) && isDefined(recordingFileExtension);
+
+ return (
+
+ {hasRecording && (
+
+ )}
+ {transcriptLoading ? (
+
+ ) : (
+
+ )}
+
+ );
+};
+
+export default defineFrontComponent({
+ universalIdentifier:
+ CALL_RECORDING_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
+ name: 'Call Recording Viewer',
+ description: 'A viewer for call recordings',
+ component: CallRecordingViewer,
+});
diff --git a/packages/twenty-apps/internal/call-recording/src/front-components/seed-call-recordings.tsx b/packages/twenty-apps/internal/call-recording/src/front-components/seed-call-recordings.tsx
new file mode 100644
index 0000000000..27a26632c0
--- /dev/null
+++ b/packages/twenty-apps/internal/call-recording/src/front-components/seed-call-recordings.tsx
@@ -0,0 +1,111 @@
+import { useEffect, useState } from 'react';
+import {
+ SEED_CALL_RECORDINGS_COMMAND_UNIVERSAL_IDENTIFIER,
+ SEED_CALL_RECORDINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
+} from 'src/constants/seed-call-recordings-universal-identifiers';
+import { MOCK_CALL_RECORDINGS } from 'src/data/mock-call-recordings';
+import { defineFrontComponent } from 'twenty-sdk';
+import { CoreApiClient } from 'twenty-sdk/generated';
+
+type SeedStatus = 'seeding' | 'done' | 'error';
+
+const fetchPeopleIds = async (
+ client: InstanceType,
+): Promise => {
+ const result: any = await client.query({
+ people: {
+ __args: { first: 50 },
+ edges: { node: { id: true } },
+ },
+ } as any);
+
+ return (
+ result?.people?.edges?.map(
+ (edge: { node: { id: string } }) => edge.node.id,
+ ) ?? []
+ );
+};
+
+const fetchWorkspaceMemberIds = async (
+ client: InstanceType,
+): Promise => {
+ const result: any = await client.query({
+ workspaceMembers: {
+ __args: { first: 50 },
+ edges: { node: { id: true } },
+ },
+ } as any);
+
+ return (
+ result?.workspaceMembers?.edges?.map(
+ (edge: { node: { id: string } }) => edge.node.id,
+ ) ?? []
+ );
+};
+
+const pickRandom = (items: T[]): T | undefined =>
+ items.length > 0 ? items[Math.floor(Math.random() * items.length)] : undefined;
+
+const SeedCallRecordings = () => {
+ const [status, setStatus] = useState('seeding');
+ const [count, setCount] = useState(0);
+
+ useEffect(() => {
+ const seed = async () => {
+ try {
+ const client = new CoreApiClient();
+
+ const [personIds, workspaceMemberIds] = await Promise.all([
+ fetchPeopleIds(client),
+ fetchWorkspaceMemberIds(client),
+ ]);
+
+ const recordsToCreate = MOCK_CALL_RECORDINGS.map((recording) => ({
+ ...recording,
+ personId: pickRandom(personIds),
+ workspaceMemberId: pickRandom(workspaceMemberIds),
+ }));
+
+ await client.mutation({
+ createCallRecordings: {
+ __args: { data: recordsToCreate as any },
+ id: true,
+ },
+ } as any);
+
+ setCount(recordsToCreate.length);
+ setStatus('done');
+ } catch {
+ setStatus('error');
+ }
+ };
+
+ seed();
+ }, []);
+
+ if (status === 'seeding') {
+ return
+
+
+
+
diff --git a/packages/twenty-companion/src/main.js b/packages/twenty-companion/src/main.js
new file mode 100644
index 0000000000..bc82859c10
--- /dev/null
+++ b/packages/twenty-companion/src/main.js
@@ -0,0 +1,1969 @@
+const { app, BrowserWindow, ipcMain, protocol, Notification, shell, nativeImage } = require('electron');
+const path = require('node:path');
+const url = require('url');
+const fs = require('fs');
+const RecallAiSdk = require('@recallai/desktop-sdk');
+const axios = require('axios');
+const OpenAI = require('openai');
+const sdkLogger = require('./sdk-logger');
+const twentyClient = require('./twenty-client');
+require('dotenv').config();
+
+const twentyIconDataUrl = require('./assets/twenty-logo-256.png');
+
+function getAppIcon() {
+ try {
+ return nativeImage.createFromDataURL(twentyIconDataUrl);
+ } catch (error) {
+ console.error('Failed to load app icon:', error);
+ return undefined;
+ }
+}
+
+// Function to get the OpenRouter headers
+function getHeaderLines() {
+ return [
+ "HTTP-Referer: https://recall.ai", // Replace with your actual app's URL
+ "X-Title: Twenty AI Notetaker"
+ ];
+}
+
+let openai = null;
+
+function getOpenAIClient() {
+ if (openai) return openai;
+ if (!process.env.OPENROUTER_KEY) return null;
+
+ openai = new OpenAI({
+ baseURL: "https://openrouter.ai/api/v1",
+ apiKey: process.env.OPENROUTER_KEY,
+ defaultHeaders: {
+ "HTTP-Referer": "https://recall.ai",
+ "X-Title": "Twenty AI Notetaker"
+ }
+ });
+ return openai;
+}
+
+// Define available models with their capabilities
+const MODELS = {
+ // Primary models
+ PRIMARY: "anthropic/claude-3.7-sonnet",
+ FALLBACKS: []
+};
+
+// Handle creating/removing shortcuts on Windows when installing/uninstalling.
+if (require('electron-squirrel-startup')) {
+ app.quit();
+}
+
+// Check Twenty CRM integration status at startup
+twentyClient.isConfigured();
+
+// Store detected meeting information
+let detectedMeeting = null;
+
+let mainWindow;
+
+const createWindow = () => {
+ // Create the browser window.
+ mainWindow = new BrowserWindow({
+ width: 1024,
+ height: 768,
+ icon: getAppIcon(),
+ webPreferences: {
+ preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY,
+ contextIsolation: true,
+ nodeIntegration: false,
+ },
+ titleBarStyle: 'hiddenInset',
+ backgroundColor: '#f9f9f9',
+ });
+
+ // Allow the debug panel header to act as a drag region
+ mainWindow.on('ready-to-show', () => {
+ try {
+ // Set regions that can be used to drag the window
+ if (process.platform === 'darwin') {
+ // Only needed on macOS
+ mainWindow.setWindowButtonVisibility(true);
+ }
+ } catch (error) {
+ console.error('Error setting drag regions:', error);
+ }
+ });
+
+ // and load the index.html of the app.
+ mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY);
+
+ // Open the DevTools in development
+ if (process.env.NODE_ENV === 'development') {
+ // mainWindow.webContents.openDevTools();
+ }
+
+ // Listen for navigation events
+ ipcMain.on('navigate', (event, page) => {
+ if (page === 'note-editor') {
+ mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY + '/../note-editor/index.html');
+ } else if (page === 'home') {
+ mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY);
+ }
+ });
+};
+
+// This method will be called when Electron has finished
+// initialization and is ready to create browser windows.
+// Some APIs can only be used after this event occurs.
+app.whenReady().then(() => {
+ if (process.platform === 'darwin' && app.dock) {
+ const dockIcon = getAppIcon();
+ if (dockIcon && !dockIcon.isEmpty()) {
+ app.dock.setIcon(dockIcon);
+ console.log('Twenty dock icon set successfully');
+ } else {
+ console.error('Failed to set dock icon: image is empty or undefined');
+ }
+ }
+
+ console.log("Registering IPC handlers...");
+ // Log all registered IPC handlers
+ console.log("IPC handlers:", Object.keys(ipcMain._invokeHandlers));
+
+ // Set up SDK logger IPC handlers
+ ipcMain.on('sdk-log', (event, logEntry) => {
+ // Forward logs from renderer to any open windows
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send('sdk-log', logEntry);
+ }
+ });
+
+ // Set up logger event listener to send logs from main to renderer
+ sdkLogger.onLog((logEntry) => {
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send('sdk-log', logEntry);
+ }
+ });
+
+ // Create meetings file if it doesn't exist
+ try {
+ if (!fs.existsSync(meetingsFilePath)) {
+ const initialData = { upcomingMeetings: [], pastMeetings: [] };
+ fs.writeFileSync(meetingsFilePath, JSON.stringify(initialData, null, 2));
+ }
+ } catch (e) {
+ console.error("Couldn't create the meetings file:", e);
+ }
+
+ // Initialize the Recall.ai SDK
+ initSDK();
+
+ createWindow();
+
+ // When the window is ready, send the initial meeting detection status
+ mainWindow.webContents.on('did-finish-load', () => {
+ // Send the initial meeting detection status
+ mainWindow.webContents.send('meeting-detection-status', { detected: detectedMeeting !== null });
+ });
+
+ // On OS X it's common to re-create a window in the app when the
+ // dock icon is clicked and there are no other windows open.
+ app.on('activate', () => {
+ if (BrowserWindow.getAllWindows().length === 0) {
+ createWindow();
+ }
+ });
+});
+
+// Quit when all windows are closed, except on macOS. There, it's common
+// for applications and their menu bar to stay active until the user quits
+// explicitly with Cmd + Q.
+app.on('window-all-closed', () => {
+ if (process.platform !== 'darwin') {
+ app.quit();
+ }
+});
+
+// In this file you can include the rest of your app's specific main process
+// code. You can also put them in separate files and import them here.
+
+// Path to meetings data file in the user's Application Support directory
+const meetingsFilePath = path.join(app.getPath('userData'), 'meetings.json');
+
+// Global state to track active recordings
+const activeRecordings = {
+ // Map of recordingId -> {noteId, platform, state}
+ recordings: {},
+
+ // Register a new recording
+ addRecording: function (recordingId, noteId, platform = 'unknown') {
+ this.recordings[recordingId] = {
+ noteId,
+ platform,
+ state: 'recording',
+ startTime: new Date()
+ };
+ console.log(`Recording registered in global state: ${recordingId} for note ${noteId}`);
+ },
+
+ // Update a recording's state
+ updateState: function (recordingId, state) {
+ if (this.recordings[recordingId]) {
+ this.recordings[recordingId].state = state;
+ console.log(`Recording ${recordingId} state updated to: ${state}`);
+ return true;
+ }
+ return false;
+ },
+
+ // Remove a recording
+ removeRecording: function (recordingId) {
+ if (this.recordings[recordingId]) {
+ delete this.recordings[recordingId];
+ console.log(`Recording ${recordingId} removed from global state`);
+ return true;
+ }
+ return false;
+ },
+
+ // Get active recording for a note
+ getForNote: function (noteId) {
+ for (const [recordingId, info] of Object.entries(this.recordings)) {
+ if (info.noteId === noteId) {
+ return { recordingId, ...info };
+ }
+ }
+ return null;
+ },
+
+ // Get all active recordings
+ getAll: function () {
+ return { ...this.recordings };
+ }
+};
+
+// File operation manager to prevent race conditions on both reads and writes
+const fileOperationManager = {
+ isProcessing: false,
+ pendingOperations: [],
+ cachedData: null,
+ lastReadTime: 0,
+
+ // Read the meetings data with caching to reduce file I/O
+ readMeetingsData: async function () {
+ // If we have cached data that's recent (less than 500ms old), use it
+ const now = Date.now();
+ if (this.cachedData && (now - this.lastReadTime < 500)) {
+ return JSON.parse(JSON.stringify(this.cachedData)); // Deep clone
+ }
+
+ try {
+ // Read from file
+ const fileData = await fs.promises.readFile(meetingsFilePath, 'utf8');
+ const data = JSON.parse(fileData);
+
+ // Update cache
+ this.cachedData = data;
+ this.lastReadTime = now;
+
+ return data;
+ } catch (error) {
+ console.error('Error reading meetings data:', error);
+ // If file doesn't exist or is invalid, return empty structure
+ return { upcomingMeetings: [], pastMeetings: [] };
+ }
+ },
+
+ // Schedule an operation that needs to update the meetings data
+ scheduleOperation: async function (operationFn) {
+ return new Promise((resolve, reject) => {
+ // Add this operation to the queue
+ this.pendingOperations.push({
+ operationFn, // This function will receive the current data and return updated data
+ resolve,
+ reject
+ });
+
+ // Process the queue if not already processing
+ if (!this.isProcessing) {
+ this.processQueue();
+ }
+ });
+ },
+
+ // Process the operation queue sequentially
+ processQueue: async function () {
+ if (this.pendingOperations.length === 0 || this.isProcessing) {
+ return;
+ }
+
+ this.isProcessing = true;
+
+ try {
+ // Get the next operation
+ const nextOp = this.pendingOperations.shift();
+
+ // Read the latest data
+ const currentData = await this.readMeetingsData();
+
+ try {
+ // Execute the operation function with the current data
+ const updatedData = await nextOp.operationFn(currentData);
+
+ // If the operation returned data, write it
+ if (updatedData) {
+ // Update cache immediately
+ this.cachedData = updatedData;
+ this.lastReadTime = Date.now();
+
+ // Write to file
+ await fs.promises.writeFile(meetingsFilePath, JSON.stringify(updatedData, null, 2));
+ }
+
+ // Resolve the operation's promise
+ nextOp.resolve({ success: true });
+ } catch (opError) {
+ console.error('Error in file operation:', opError);
+ nextOp.reject(opError);
+ }
+ } catch (error) {
+ console.error('Error in file operation manager:', error);
+
+ // If there was an operation that failed, reject its promise
+ if (this.pendingOperations.length > 0) {
+ const failedOp = this.pendingOperations.shift();
+ failedOp.reject(error);
+ }
+ } finally {
+ this.isProcessing = false;
+
+ // Check if more operations were added while we were processing
+ if (this.pendingOperations.length > 0) {
+ setImmediate(() => this.processQueue());
+ }
+ }
+ },
+
+ // Helper to write data directly - internally uses scheduleOperation
+ writeData: async function (data) {
+ return this.scheduleOperation(() => data); // Simply return the data to write
+ }
+};
+
+function buildRecallRecordingUrl(uploadId) {
+ if (!uploadId) return null;
+ return `recall://recording/${uploadId}`;
+}
+
+// Create a desktop SDK upload token
+async function createDesktopSdkUpload() {
+ try {
+ const response = await axios.get("http://localhost:13373/start-recording", { timeout: 10000 });
+
+ if (response.data.status !== 'success') {
+ console.error("Failed to create upload token:", response.data.message);
+ return null;
+ } else {
+ console.log("Upload token created successfully:", response.data.upload_token);
+ return response.data;
+ }
+ } catch (error) {
+ console.error("Error creating upload token:", JSON.stringify(error.errors || error.message || error));
+ if (error.response) {
+ console.error("Response data:", error.response.data);
+ console.error("Response status:", error.response.status);
+ }
+ return null;
+ }
+}
+
+// Poll Recall for the audio URL then call the Twenty end-recording logic function.
+// Recall may need time to process the upload after progress hits 100%.
+async function endCallRecordingWithRetry(windowId, maxAttempts = 10, delayMs = 5000) {
+ try {
+ const meetingsData = await fileOperationManager.readMeetingsData();
+ const meeting = meetingsData.pastMeetings.find(
+ (meetingItem) => meetingItem.recallUploadId === windowId
+ || meetingItem.recordingId === windowId,
+ );
+
+ if (!meeting?.twentyRecordId || !meeting?.recallRecordingId) {
+ console.log('[Twenty] No Twenty record or Recall recording ID found, skipping end-recording');
+ return;
+ }
+
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ try {
+ const recallResponse = await axios.get(
+ `http://localhost:13373/recording/${meeting.recallRecordingId}`,
+ { timeout: 15000 },
+ );
+
+ const audioUrl = recallResponse.data?.video_url || null;
+ const transcriptUrl = recallResponse.data?.transcript_url || null;
+
+ if (audioUrl) {
+ await twentyClient.endCallRecording({
+ callRecordingId: meeting.twentyRecordId,
+ audioUrl,
+ transcriptUrl,
+ participants: meeting.participants,
+ localTranscript: meeting.transcript || [],
+ });
+ return;
+ }
+ } catch (error) {
+ console.error(`[Twenty] Attempt ${attempt}/${maxAttempts} — error fetching recording:`, error.message);
+ }
+
+ if (attempt < maxAttempts) {
+ console.log(`[Twenty] Audio URL not ready, retrying in ${delayMs / 1000}s (${attempt}/${maxAttempts})`);
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
+ }
+ }
+
+ console.error('[Twenty] Audio URL never became available, giving up');
+ } catch (error) {
+ console.error('Failed to end callRecording in Twenty:', error.message);
+ }
+}
+
+// Initialize the Recall.ai SDK
+function initSDK() {
+ console.log("Initializing Recall.ai SDK");
+
+ // Log the SDK initialization
+ sdkLogger.logApiCall('init', {
+ dev: process.env.NODE_ENV === 'development',
+ api_url: process.env.RECALLAI_API_URL
+ });
+
+ RecallAiSdk.init({
+ api_url: process.env.RECALLAI_API_URL
+ });
+
+ RecallAiSdk.addEventListener('permission-status', (evt) => {
+ console.log(`Permission: ${evt.permission}, Status: ${evt.status}`);
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send('permission-status', evt);
+ }
+ });
+
+ RecallAiSdk.addEventListener('log', (evt) => {
+ if (evt.level === 'debug') return;
+
+ const prefix = `[SDK ${evt.level}] [${evt.subsystem}/${evt.category}]`;
+ if (evt.level === 'error') {
+ console.error(prefix, evt.message);
+ } else if (evt.level === 'warning') {
+ console.warn(prefix, evt.message);
+ } else {
+ console.log(prefix, evt.message);
+ }
+
+ // Track active speaker from LibbotMeetingRecorder participant updates
+ if (evt.message && evt.message.includes('isActiveSpeaker: true')) {
+ const nameMatch = evt.message.match(/name:\s*([^,]+),/);
+ if (nameMatch) {
+ const speakerName = nameMatch[1].trim();
+ if (speakerName !== currentActiveSpeaker) {
+ currentActiveSpeaker = speakerName;
+ console.log(`[speaker-debug] Active speaker changed to: ${currentActiveSpeaker}`);
+ }
+ }
+ }
+ });
+
+ // Listen for meeting detected events
+ RecallAiSdk.addEventListener('meeting-detected', (evt) => {
+ console.log("Meeting detected:", evt);
+
+ // Log the meeting detected event
+ sdkLogger.logEvent('meeting-detected', {
+ platform: evt.window.platform,
+ windowId: evt.window.id
+ });
+
+ detectedMeeting = evt;
+
+ // Map platform codes to readable names
+ const platformNames = {
+ 'zoom': 'Zoom',
+ 'google-meet': 'Google Meet',
+ 'slack': 'Slack',
+ 'teams': 'Microsoft Teams'
+ };
+
+ // Get a user-friendly platform name, or use the raw platform name if not in our map
+ const platformName = platformNames[evt.window.platform] || evt.window.platform;
+
+ // Send a notification
+ let notification = new Notification({
+ title: `${platformName} Meeting Detected`,
+ body: platformName,
+ });
+
+ // Handle notification click
+ notification.on('click', () => {
+ console.log("Notification clicked for platform:", platformName);
+ joinDetectedMeeting();
+ });
+
+ notification.show();
+
+ // Send the meeting detected status to the renderer process
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send('meeting-detection-status', { detected: true });
+ }
+ });
+
+ // Listen for meeting updated events (to capture title and URL)
+ // NOTE: meeting-detected events do NOT guarantee title and URL will be populated.
+ // The meeting title and URL are only reliably available in meeting-updated events,
+ // which fire as the meeting metadata becomes available after initial detection.
+ RecallAiSdk.addEventListener('meeting-updated', async (evt) => {
+ console.log("Meeting updated:", evt);
+
+ const { window } = evt;
+
+ // Log the meeting updated event with the URL for tracking purposes
+ sdkLogger.logEvent('meeting-updated', {
+ platform: window.platform,
+ windowId: window.id,
+ title: window.title,
+ url: window.url
+ });
+
+ // Update the detectedMeeting object with the new information
+ if (detectedMeeting && detectedMeeting.window.id === window.id) {
+ detectedMeeting = {
+ ...detectedMeeting,
+ window: {
+ ...detectedMeeting.window,
+ title: window.title,
+ url: window.url
+ }
+ };
+
+ console.log("Updated meeting title:", window.title);
+
+ // If a note has already been created for this meeting, update its title retroactively
+ if (window.title && global.activeMeetingIds && global.activeMeetingIds[window.id]) {
+ const noteId = global.activeMeetingIds[window.id].noteId;
+
+ if (noteId) {
+ console.log("Updating existing note title for:", noteId);
+
+ try {
+ // Read the current meetings data
+ const meetingsData = await fileOperationManager.readMeetingsData();
+
+ // Find the meeting in pastMeetings
+ const meeting = meetingsData.pastMeetings.find(m => m.id === noteId);
+
+ if (meeting) {
+ const oldTitle = meeting.title;
+
+ // Update the title
+ meeting.title = window.title;
+
+ // Save the updated data
+ await fileOperationManager.writeData(meetingsData);
+ console.log(`Successfully updated meeting title from "${oldTitle}" to "${window.title}"`);
+
+ // Notify the renderer to update the UI
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send('meeting-title-updated', {
+ meetingId: noteId,
+ newTitle: window.title
+ });
+ }
+ } else {
+ console.error("Meeting not found in pastMeetings with ID:", noteId);
+ }
+ } catch (error) {
+ console.error("Error updating meeting title:", error);
+ }
+ }
+ }
+ }
+ });
+
+ // Listen for meeting closed events
+ RecallAiSdk.addEventListener('meeting-closed', async (evt) => {
+ console.log("Meeting closed:", evt);
+
+ // Log the SDK meeting-closed event
+ sdkLogger.logEvent('meeting-closed', {
+ windowId: evt.window.id
+ });
+
+ // Clean up the global tracking when a meeting ends
+ if (evt.window && evt.window.id && global.activeMeetingIds && global.activeMeetingIds[evt.window.id]) {
+ console.log(`Cleaning up meeting tracking for: ${evt.window.id}`);
+ delete global.activeMeetingIds[evt.window.id];
+ }
+
+ detectedMeeting = null;
+
+ // Send the meeting closed status to the renderer process
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send('meeting-detection-status', { detected: false });
+ }
+ });
+
+ // Listen for recording ended events
+ RecallAiSdk.addEventListener('recording-ended', async (evt) => {
+ console.log("Recording ended:", evt);
+
+ // Log the SDK recording-ended event
+ sdkLogger.logEvent('recording-ended', {
+ windowId: evt.window.id
+ });
+
+ try {
+ // Update the note with recording information
+ await updateNoteWithRecordingInfo(evt.window.id);
+
+ // Add a small delay before uploading (good practice for file system operations)
+ setTimeout(async () => {
+ try {
+ // Try to get a new upload token for the upload if needed
+ const uploadData = await createDesktopSdkUpload();
+
+ if (uploadData && uploadData.upload_token) {
+ console.log('Uploading recording with new upload token:', uploadData.upload_token);
+
+ // Log the uploadRecording API call
+ sdkLogger.logApiCall('uploadRecording', {
+ windowId: evt.window.id,
+ uploadToken: `${uploadData.upload_token.substring(0, 8)}...` // Log truncated token for security
+ });
+
+ await RecallAiSdk.uploadRecording({
+ windowId: evt.window.id,
+ uploadToken: uploadData.upload_token
+ });
+ } else {
+ // Fallback to regular upload
+ console.log('Uploading recording without new token');
+
+ // Log the uploadRecording API call (fallback)
+ sdkLogger.logApiCall('uploadRecording', {
+ windowId: evt.window.id
+ });
+
+ await RecallAiSdk.uploadRecording({ windowId: evt.window.id });
+ }
+ } catch (uploadError) {
+ console.error('Error during upload:', uploadError);
+ // Fallback to regular upload
+
+ // Log the uploadRecording API call (error fallback)
+ sdkLogger.logApiCall('uploadRecording', {
+ windowId: evt.window.id,
+ error: 'Fallback after error'
+ });
+
+ await RecallAiSdk.uploadRecording({ windowId: evt.window.id });
+ }
+ }, 3000); // Wait 3 seconds before uploading
+ } catch (error) {
+ console.error("Error handling recording ended:", error);
+ }
+ });
+
+ RecallAiSdk.addEventListener('permissions-granted', async (evt) => {
+ console.log("PERMISSIONS GRANTED");
+ });
+
+ // Track upload progress
+ RecallAiSdk.addEventListener('upload-progress', async (evt) => {
+ const { progress, window } = evt;
+ console.log(`Upload progress: ${progress}%`);
+
+ // Log the SDK upload-progress event
+ // sdkLogger.logEvent('upload-progress', {
+ // windowId: window.id,
+ // progress
+ // });
+
+ if (progress === 100) {
+ console.log(`Upload completed for recording: ${window.id}`);
+
+ if (twentyClient.isConfigured()) {
+ endCallRecordingWithRetry(window.id);
+ }
+ }
+ });
+
+ // Track SDK state changes
+ RecallAiSdk.addEventListener('sdk-state-change', async (evt) => {
+ const { sdk: { state: { code } }, window } = evt;
+ console.log("Recording state changed:", code, "for window:", window?.id);
+
+ // Log the SDK sdk-state-change event
+ sdkLogger.logEvent('sdk-state-change', {
+ state: code,
+ windowId: window?.id
+ });
+
+ // Update recording state in our global tracker
+ if (window && window.id) {
+ // Get the meeting note ID associated with this window
+ let noteId = null;
+ if (global.activeMeetingIds && global.activeMeetingIds[window.id]) {
+ noteId = global.activeMeetingIds[window.id].noteId;
+ }
+
+ // Update the recording state in our tracker
+ if (code === 'recording') {
+ console.log('Recording in progress...');
+ if (noteId) {
+ // If recording started, add it to our active recordings
+ activeRecordings.addRecording(window.id, noteId, window.platform || 'unknown');
+ }
+ } else if (code === 'paused') {
+ console.log('Recording paused');
+ activeRecordings.updateState(window.id, 'paused');
+ } else if (code === 'idle') {
+ console.log('Recording stopped');
+ activeRecordings.removeRecording(window.id);
+ }
+
+ // Notify renderer process about recording state change
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send('recording-state-change', {
+ recordingId: window.id,
+ state: code,
+ noteId
+ });
+ }
+ }
+ });
+
+ // Listen for real-time transcript events
+ RecallAiSdk.addEventListener('realtime-event', async (evt) => {
+ // Only log non-video frame events to prevent flooding the logger
+ if (evt.event !== 'video_separate_png.data') {
+ console.log("Received realtime event:", evt.event);
+
+ // Log the SDK realtime-event event
+ sdkLogger.logEvent('realtime-event', {
+ eventType: evt.event,
+ windowId: evt.window?.id
+ });
+ }
+
+ // Handle different event types
+ if (evt.event === 'transcript.data' && evt.data && evt.data.data) {
+ await processTranscriptData(evt);
+ }
+ else if (evt.event === 'transcript.provider_data' && evt.data && evt.data.data) {
+ await processTranscriptProviderData(evt);
+ }
+ else if (evt.event === 'participant_events.join' && evt.data && evt.data.data) {
+ await processParticipantJoin(evt);
+ }
+ else if (evt.event === 'video_separate_png.data' && evt.data && evt.data.data) {
+ await processVideoFrame(evt);
+ }
+ });
+
+ // Handle errors
+ RecallAiSdk.addEventListener('error', async (evt) => {
+ console.error("RecallAI SDK Error:", evt);
+ const { type, message } = evt;
+
+ // Log the SDK error event
+ sdkLogger.logEvent('error', {
+ errorType: type,
+ errorMessage: message
+ });
+
+ // Show notification for errors
+ let notification = new Notification({
+ title: 'Recording Error',
+ body: `Error: ${type} - ${message}`,
+ });
+ notification.show();
+ });
+}
+
+// Handle saving meetings data
+ipcMain.handle('saveMeetingsData', async (event, data) => {
+ try {
+ // Use the file operation manager to safely write the file
+ await fileOperationManager.writeData(data);
+ return { success: true };
+ } catch (error) {
+ console.error('Failed to save meetings data:', error);
+ return { success: false, error: error.message };
+ }
+});
+
+// Debug handler to check if IPC handlers are registered
+ipcMain.handle('debugGetHandlers', async () => {
+ console.log("Checking registered IPC handlers...");
+ const handlers = Object.keys(ipcMain._invokeHandlers);
+ console.log("Registered handlers:", handlers);
+ return handlers;
+});
+
+// Handler to get active recording ID for a note
+ipcMain.handle('getActiveRecordingId', async (event, noteId) => {
+ console.log(`getActiveRecordingId called for note: ${noteId}`);
+
+ try {
+ // If noteId is provided, get recording for that specific note
+ if (noteId) {
+ const recordingInfo = activeRecordings.getForNote(noteId);
+ return {
+ success: true,
+ data: recordingInfo
+ };
+ }
+
+ // Otherwise return all active recordings
+ return {
+ success: true,
+ data: activeRecordings.getAll()
+ };
+ } catch (error) {
+ console.error('Error getting active recording ID:', error);
+ return { success: false, error: error.message };
+ }
+});
+
+// Handle deleting a meeting
+ipcMain.handle('deleteMeeting', async (event, meetingId) => {
+ try {
+ console.log(`Deleting meeting with ID: ${meetingId}`);
+
+ // Read current data
+ const fileData = await fs.promises.readFile(meetingsFilePath, 'utf8');
+ const meetingsData = JSON.parse(fileData);
+
+ // Find the meeting
+ const pastMeetingIndex = meetingsData.pastMeetings.findIndex(meeting => meeting.id === meetingId);
+ const upcomingMeetingIndex = meetingsData.upcomingMeetings.findIndex(meeting => meeting.id === meetingId);
+
+ let meetingDeleted = false;
+ let recordingId = null;
+
+ // Remove from past meetings if found
+ if (pastMeetingIndex !== -1) {
+ // Store the recording ID for later cleanup if needed
+ recordingId = meetingsData.pastMeetings[pastMeetingIndex].recordingId;
+
+ // Remove the meeting
+ meetingsData.pastMeetings.splice(pastMeetingIndex, 1);
+ meetingDeleted = true;
+ }
+
+ // Remove from upcoming meetings if found
+ if (upcomingMeetingIndex !== -1) {
+ // Store the recording ID for later cleanup if needed
+ recordingId = meetingsData.upcomingMeetings[upcomingMeetingIndex].recordingId;
+
+ // Remove the meeting
+ meetingsData.upcomingMeetings.splice(upcomingMeetingIndex, 1);
+ meetingDeleted = true;
+ }
+
+ if (!meetingDeleted) {
+ return { success: false, error: 'Meeting not found' };
+ }
+
+ // Save the updated data
+ await fileOperationManager.writeData(meetingsData);
+
+ // If the meeting had a recording, cleanup the reference in the global tracking
+ if (recordingId && global.activeMeetingIds && global.activeMeetingIds[recordingId]) {
+ console.log(`Cleaning up tracking for deleted meeting with recording ID: ${recordingId}`);
+ delete global.activeMeetingIds[recordingId];
+ }
+
+ console.log(`Successfully deleted meeting: ${meetingId}`);
+ return { success: true };
+ } catch (error) {
+ console.error('Error deleting meeting:', error);
+ return { success: false, error: error.message };
+ }
+});
+
+// Handle generating AI summary for a meeting (non-streaming)
+ipcMain.handle('generateMeetingSummary', async (event, meetingId) => {
+ try {
+ console.log(`Manual summary generation requested for meeting: ${meetingId}`);
+
+ // Read current data
+ const fileData = await fs.promises.readFile(meetingsFilePath, 'utf8');
+ const meetingsData = JSON.parse(fileData);
+
+ // Find the meeting
+ const pastMeetingIndex = meetingsData.pastMeetings.findIndex(meeting => meeting.id === meetingId);
+
+ if (pastMeetingIndex === -1) {
+ return { success: false, error: 'Meeting not found' };
+ }
+
+ const meeting = meetingsData.pastMeetings[pastMeetingIndex];
+
+ // Check if there's a transcript to summarize
+ if (!meeting.transcript || meeting.transcript.length === 0) {
+ return {
+ success: false,
+ error: 'No transcript available for this meeting'
+ };
+ }
+
+ // Log summary generation to console instead of showing a notification
+ console.log('Generating AI summary for meeting: ' + meetingId);
+
+ // Generate the summary
+ const summary = await generateMeetingSummary(meeting);
+
+ // Get meeting title for use in the new content
+ const meetingTitle = meeting.title || "Meeting Notes";
+
+ const recallLink = meeting.recallUrl
+ ? `\n\n---\nRecording: ${meeting.recallUrl}`
+ : '';
+
+ // Create content with the AI-generated summary
+ meeting.content = `# ${meetingTitle}\n\n${summary}${recallLink}`;
+
+ meeting.hasSummary = true;
+
+ // Save the updated data with summary
+ await fileOperationManager.writeData(meetingsData);
+
+ console.log('Updated meeting note with AI summary');
+
+ // Notify the renderer to refresh the note if it's open
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send('summary-generated', meetingId);
+ }
+
+ return {
+ success: true,
+ summary
+ };
+ } catch (error) {
+ console.error('Error generating meeting summary:', error);
+ return { success: false, error: error.message };
+ }
+});
+
+// Handle starting a manual desktop recording
+ipcMain.handle('startManualRecording', async (event, meetingId) => {
+ try {
+ console.log(`Starting manual desktop recording for meeting: ${meetingId}`);
+
+ // Read current data
+ const fileData = await fs.promises.readFile(meetingsFilePath, 'utf8');
+ const meetingsData = JSON.parse(fileData);
+
+ // Find the meeting
+ const pastMeetingIndex = meetingsData.pastMeetings.findIndex(meeting => meeting.id === meetingId);
+
+ if (pastMeetingIndex === -1) {
+ return { success: false, error: 'Meeting not found' };
+ }
+
+ const meeting = meetingsData.pastMeetings[pastMeetingIndex];
+
+ try {
+ // Prepare desktop audio recording - this is the key difference from our previous implementation
+ // It returns a key that we use as the window ID
+
+ // Log the prepareDesktopAudioRecording API call
+ sdkLogger.logApiCall('prepareDesktopAudioRecording');
+
+ const key = await RecallAiSdk.prepareDesktopAudioRecording();
+ console.log('Prepared desktop audio recording with key:', key);
+
+ // Create a recording token
+ const uploadData = await createDesktopSdkUpload();
+ if (!uploadData || !uploadData.upload_token) {
+ return { success: false, error: 'Failed to create recording token' };
+ }
+
+ // Store the recording ID in the meeting
+ meeting.recordingId = key;
+
+ // Store Recall upload/recording IDs for later linking
+ meeting.recallUploadId = uploadData.upload_id;
+ meeting.recallRecordingId = uploadData.recording_id;
+ meeting.recallUrl = buildRecallRecordingUrl(uploadData.upload_id);
+
+ // Initialize transcript array if not present
+ if (!meeting.transcript) {
+ meeting.transcript = [];
+ }
+
+ // Store tracking info for the recording
+ global.activeMeetingIds = global.activeMeetingIds || {};
+ global.activeMeetingIds[key] = {
+ platformName: 'Desktop Recording',
+ noteId: meetingId
+ };
+
+ // Register the recording in our active recordings tracker
+ activeRecordings.addRecording(key, meetingId, 'Desktop Recording');
+
+ // Save the updated data
+ await fileOperationManager.writeData(meetingsData);
+
+ // Start recording with the key from prepareDesktopAudioRecording
+ console.log('Starting desktop recording with key:', key);
+
+ // Log the startRecording API call
+ sdkLogger.logApiCall('startRecording', {
+ windowId: key,
+ uploadToken: `${uploadData.upload_token.substring(0, 8)}...` // Log truncated token for security
+ });
+
+ await RecallAiSdk.startRecording({
+ windowId: key,
+ uploadToken: uploadData.upload_token
+ });
+
+ return {
+ success: true,
+ recordingId: key
+ };
+ } catch (sdkError) {
+ console.error('RecallAI SDK error:', sdkError);
+ return { success: false, error: 'Failed to prepare desktop recording: ' + sdkError.message };
+ }
+ } catch (error) {
+ console.error('Error starting manual recording:', error);
+ return { success: false, error: error.message };
+ }
+});
+
+// Handle stopping a manual desktop recording
+ipcMain.handle('stopManualRecording', async (event, recordingId) => {
+ try {
+ console.log(`Stopping manual desktop recording: ${recordingId}`);
+
+ // Stop the recording - using the windowId property as shown in the reference
+
+ // Log the stopRecording API call
+ sdkLogger.logApiCall('stopRecording', {
+ windowId: recordingId
+ });
+
+ // Update our active recordings tracker
+ activeRecordings.updateState(recordingId, 'stopping');
+
+ await RecallAiSdk.stopRecording({
+ windowId: recordingId
+ });
+
+ // The recording-ended event will be triggered automatically,
+ // which will handle uploading and generating the summary
+
+ return { success: true };
+ } catch (error) {
+ console.error('Error stopping manual recording:', error);
+ return { success: false, error: error.message };
+ }
+});
+
+// Handle generating AI summary with streaming
+ipcMain.handle('generateMeetingSummaryStreaming', async (event, meetingId) => {
+ try {
+ console.log(`Streaming summary generation requested for meeting: ${meetingId}`);
+
+ // Read current data
+ const fileData = await fs.promises.readFile(meetingsFilePath, 'utf8');
+ const meetingsData = JSON.parse(fileData);
+
+ // Find the meeting
+ const pastMeetingIndex = meetingsData.pastMeetings.findIndex(meeting => meeting.id === meetingId);
+
+ if (pastMeetingIndex === -1) {
+ return { success: false, error: 'Meeting not found' };
+ }
+
+ const meeting = meetingsData.pastMeetings[pastMeetingIndex];
+
+ // Check if there's a transcript to summarize
+ if (!meeting.transcript || meeting.transcript.length === 0) {
+ return {
+ success: false,
+ error: 'No transcript available for this meeting'
+ };
+ }
+
+ // Log summary generation to console instead of showing a notification
+ console.log('Generating streaming summary for meeting: ' + meetingId);
+
+ // Get meeting title for use in the new content
+ const meetingTitle = meeting.title || "Meeting Notes";
+
+ // Initial content with placeholders
+ meeting.content = `# ${meetingTitle}\n\nGenerating summary...`;
+
+ // Update the note on the frontend right away
+ mainWindow.webContents.send('summary-update', {
+ meetingId,
+ content: meeting.content
+ });
+
+ const recallLink = meeting.recallUrl
+ ? `\n\n---\nRecording: ${meeting.recallUrl}`
+ : '';
+
+ // Create progress callback for streaming updates
+ const streamProgress = (currentText) => {
+ // Update content with current streaming text
+ meeting.content = `# ${meetingTitle}\n\n## AI-Generated Meeting Summary\n${currentText}${recallLink}`;
+
+ // Send immediate update to renderer - don't debounce or delay this
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ try {
+ // Force immediate send of the update
+ mainWindow.webContents.send('summary-update', {
+ meetingId,
+ content: meeting.content,
+ timestamp: Date.now()
+ });
+ } catch (err) {
+ console.error('Error sending streaming update to renderer:', err);
+ }
+ }
+ };
+
+ // Generate summary with streaming
+ const summary = await generateMeetingSummary(meeting, streamProgress);
+
+ // Make sure the final content is set correctly
+ meeting.content = `# ${meetingTitle}\n\n${summary}${recallLink}`;
+ meeting.hasSummary = true;
+
+ // Save the updated data with summary
+ await fileOperationManager.writeData(meetingsData);
+
+ console.log('Updated meeting note with AI summary (streaming)');
+
+ // Final notification to renderer
+ mainWindow.webContents.send('summary-generated', meetingId);
+
+ return {
+ success: true,
+ summary
+ };
+ } catch (error) {
+ console.error('Error generating streaming summary:', error);
+ return { success: false, error: error.message };
+ }
+});
+
+// Handle loading meetings data
+ipcMain.handle('loadMeetingsData', async () => {
+ try {
+ // Use our file operation manager to safely read the data
+ const data = await fileOperationManager.readMeetingsData();
+
+ // Return the data
+ return {
+ success: true,
+ data: data
+ };
+ } catch (error) {
+ console.error('Failed to load meetings data:', error);
+ return { success: false, error: error.message };
+ }
+});
+
+// Function to create a new meeting note and start recording
+async function createMeetingNoteAndRecord(platformName) {
+ console.log("Creating meeting note for platform:", platformName);
+ try {
+ if (!detectedMeeting) {
+ console.error('No active meeting detected');
+ return;
+ }
+
+ // Guard against duplicate calls for the same meeting window
+ global.activeMeetingIds = global.activeMeetingIds || {};
+
+ if (global.activeMeetingIds[detectedMeeting.window.id]?.noteId) {
+ console.log("Meeting already being recorded for window:", detectedMeeting.window.id);
+ return global.activeMeetingIds[detectedMeeting.window.id].noteId;
+ }
+
+ console.log("Detected meeting info:", detectedMeeting.window.id, detectedMeeting.window.platform);
+
+ // Store the meeting window ID for later reference with transcript events
+ global.activeMeetingIds[detectedMeeting.window.id] = { platformName };
+
+ // Read the current meetings data
+ let meetingsData;
+ try {
+ const fileData = await fs.promises.readFile(meetingsFilePath, 'utf8');
+ meetingsData = JSON.parse(fileData);
+ } catch (error) {
+ console.error('Error reading meetings data:', error);
+ meetingsData = { upcomingMeetings: [], pastMeetings: [] };
+ }
+
+ // Generate a unique ID for the new meeting
+ const id = 'meeting-' + Date.now();
+
+ // Current date and time
+ const now = new Date();
+
+ // Use the actual meeting title if available, otherwise fall back to platform name + time
+ // NOTE: meeting-updated may fire after the user clicks to join, so this might not be
+ // populated yet. The meeting-updated handler will update the title retroactively if needed.
+ const meetingTitle = detectedMeeting.window.title
+ ? detectedMeeting.window.title
+ : `${platformName} Meeting - ${now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
+
+ // Create a template for the note content
+ const template = `# ${meetingTitle}\nRecording: In Progress...`;
+
+ // Create a new meeting object
+ const newMeeting = {
+ id: id,
+ type: 'document',
+ title: meetingTitle,
+ subtitle: now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
+ hasDemo: false,
+ date: now.toISOString(),
+ participants: [],
+ content: template,
+ recordingId: detectedMeeting.window.id,
+ platform: platformName,
+ transcript: [] // Initialize an empty array for transcript data
+ };
+
+ // Update the active meeting tracking with the note ID
+ if (global.activeMeetingIds && global.activeMeetingIds[detectedMeeting.window.id]) {
+ global.activeMeetingIds[detectedMeeting.window.id].noteId = id;
+ }
+
+ // Create a call recording record in Twenty CRM
+ if (twentyClient.isConfigured()) {
+ try {
+ const twentyRecord = await twentyClient.createCallRecording(meetingTitle);
+
+ if (twentyRecord?.id) {
+ newMeeting.twentyRecordId = twentyRecord.id;
+
+ if (global.activeMeetingIds && global.activeMeetingIds[detectedMeeting.window.id]) {
+ global.activeMeetingIds[detectedMeeting.window.id].twentyRecordId = twentyRecord.id;
+ }
+
+ console.log('Created callRecording in Twenty:', twentyRecord.id);
+ }
+ } catch (error) {
+ console.error('Failed to create callRecording in Twenty:', error.message);
+ }
+ }
+
+ // Register this meeting in our active recordings tracker (even before starting)
+ // This ensures the UI knows about it immediately
+ activeRecordings.addRecording(detectedMeeting.window.id, id, platformName);
+
+ // Add to pastMeetings
+ meetingsData.pastMeetings.unshift(newMeeting);
+
+ // Save the updated data
+ console.log(`Saving meeting data to ${meetingsFilePath} with ID: ${id}`);
+ await fileOperationManager.writeData(meetingsData);
+
+ // Verify the file was written by reading it back
+ try {
+ const verifyData = await fs.promises.readFile(meetingsFilePath, 'utf8');
+ const parsedData = JSON.parse(verifyData);
+ const verifyMeeting = parsedData.pastMeetings.find(m => m.id === id);
+
+ if (verifyMeeting) {
+ console.log(`Successfully verified meeting ${id} was saved`);
+
+ // Tell the renderer to open the new note
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ // We need a significant delay to make sure the file is fully processed and loaded
+ // This ensures the renderer has time to process the file and recognize the new meeting
+ setTimeout(async () => {
+ try {
+ // Force a file reload before sending the message
+ await fs.promises.readFile(meetingsFilePath, 'utf8');
+
+ console.log(`Sending IPC message to open meeting note: ${id}`);
+ mainWindow.webContents.send('open-meeting-note', id);
+
+ // Send another message after 2 seconds as a backup
+ setTimeout(() => {
+ console.log(`Sending backup IPC message to open meeting note: ${id}`);
+ mainWindow.webContents.send('open-meeting-note', id);
+ }, 2000);
+ } catch (error) {
+ console.error('Error before sending open-meeting-note message:', error);
+ }
+ }, 1500); // Increased delay for safety
+ }
+ } else {
+ console.error(`Meeting ${id} not found in saved data!`);
+ }
+ } catch (verifyError) {
+ console.error('Error verifying saved data:', verifyError);
+ }
+
+ // Start recording with upload token
+ console.log('Starting recording for meeting:', detectedMeeting.window.id);
+
+ try {
+ // Get upload token
+ const uploadData = await createDesktopSdkUpload();
+
+ if (!uploadData || !uploadData.upload_token) {
+ console.error('Failed to get upload token. Recording without upload token.');
+
+ // Log the startRecording API call (no token fallback)
+ sdkLogger.logApiCall('startRecording', {
+ windowId: detectedMeeting.window.id
+ });
+
+ await RecallAiSdk.startRecording({
+ windowId: detectedMeeting.window.id
+ });
+ } else {
+ console.log('Starting recording with upload token:', uploadData.upload_token);
+
+ // Store Recall upload/recording IDs on the meeting for later linking
+ const savedMeeting = meetingsData.pastMeetings.find(m => m.id === id);
+ if (savedMeeting) {
+ savedMeeting.recallUploadId = uploadData.upload_id;
+ savedMeeting.recallRecordingId = uploadData.recording_id;
+ savedMeeting.recallUrl = buildRecallRecordingUrl(uploadData.upload_id);
+ await fileOperationManager.writeData(meetingsData);
+ }
+
+ // Log the startRecording API call with upload token
+ sdkLogger.logApiCall('startRecording', {
+ windowId: detectedMeeting.window.id,
+ uploadToken: `${uploadData.upload_token.substring(0, 8)}...` // Log truncated token for security
+ });
+
+ await RecallAiSdk.startRecording({
+ windowId: detectedMeeting.window.id,
+ uploadToken: uploadData.upload_token
+ });
+ }
+ } catch (error) {
+ console.error('Error starting recording with upload token:', error);
+
+ // Fallback to recording without token
+
+ // Log the startRecording API call (error fallback)
+ sdkLogger.logApiCall('startRecording', {
+ windowId: detectedMeeting.window.id,
+ error: 'Fallback after error'
+ });
+
+ await RecallAiSdk.startRecording({
+ windowId: detectedMeeting.window.id
+ });
+ }
+
+ return id;
+ } catch (error) {
+ console.error('Error creating meeting note:', error);
+ }
+}
+
+// Function to process video frames
+async function processVideoFrame(evt) {
+ try {
+ const windowId = evt.window?.id;
+ if (!windowId) {
+ console.error("Missing window ID in video frame event");
+ return;
+ }
+
+ // Check if we have this meeting in our active meetings
+ if (!global.activeMeetingIds || !global.activeMeetingIds[windowId]) {
+ console.log(`No active meeting found for window ID: ${windowId}`);
+ return;
+ }
+
+ const noteId = global.activeMeetingIds[windowId].noteId;
+ if (!noteId) {
+ console.log(`No note ID found for window ID: ${windowId}`);
+ return;
+ }
+
+ // Extract the video data
+ const frameData = evt.data.data;
+ if (!frameData || !frameData.buffer) {
+ console.log("No video frame data in event");
+ return;
+ }
+
+ // Get data from the event
+ const frameBuffer = frameData.buffer; // base64 encoded PNG
+ const frameTimestamp = frameData.timestamp;
+ const frameType = frameData.type; // 'webcam' or 'screenshare'
+ const participantData = frameData.participant;
+
+ // Extract participant info
+ const participantId = participantData?.id;
+ const participantName = participantData?.name || 'Unknown';
+
+ // Log minimal info to avoid flooding the console
+ // console.log(`Received ${frameType} frame from ${participantName} (ID: ${participantId}) at ${frameTimestamp.absolute}`);
+
+ // Send the frame to the renderer
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send('video-frame', {
+ noteId,
+ participantId,
+ participantName,
+ frameType,
+ buffer: frameBuffer,
+ timestamp: frameTimestamp
+ });
+ }
+ } catch (error) {
+ console.error('Error processing video frame:', error);
+ }
+}
+
+// Function to process participant join events
+async function processParticipantJoin(evt) {
+ try {
+ const windowId = evt.window?.id;
+ if (!windowId) {
+ console.error("Missing window ID in participant join event");
+ return;
+ }
+
+ // Check if we have this meeting in our active meetings
+ if (!global.activeMeetingIds || !global.activeMeetingIds[windowId]) {
+ console.log(`No active meeting found for window ID: ${windowId}`);
+ return;
+ }
+
+ const noteId = global.activeMeetingIds[windowId].noteId;
+ if (!noteId) {
+ console.log(`No note ID found for window ID: ${windowId}`);
+ return;
+ }
+
+ // Extract the participant data
+ const participantData = evt.data.data.participant;
+ if (!participantData) {
+ console.log("No participant data in event");
+ return;
+ }
+
+ const participantName = participantData.name || "Unknown Participant";
+ const participantId = participantData.id;
+ const isHost = participantData.is_host;
+ const platform = participantData.platform;
+
+ console.log(`Participant joined: ${participantName} (ID: ${participantId}, Host: ${isHost})`);
+
+ // Skip "Host" and "Guest" generic names
+ if (participantName === "Host" || participantName === "Guest" || participantName.includes("others") || (participantName.split(" ").length > 3)) {
+ console.log(`Skipping generic participant name: ${participantName}`);
+ return;
+ }
+
+ // Use the file operation manager to safely update the meetings data
+ await fileOperationManager.scheduleOperation(async (meetingsData) => {
+ // Find the meeting note with this ID
+ const noteIndex = meetingsData.pastMeetings.findIndex(meeting => meeting.id === noteId);
+ if (noteIndex === -1) {
+ console.log(`No meeting note found with ID: ${noteId}`);
+ return null; // Return null to indicate no changes needed
+ }
+
+ // Get the meeting and initialize participants array if needed
+ const meeting = meetingsData.pastMeetings[noteIndex];
+ if (!meeting.participants) {
+ meeting.participants = [];
+ }
+
+ // Check if participant already exists (based on ID)
+ const existingParticipantIndex = meeting.participants.findIndex(p => p.id === participantId);
+
+ if (existingParticipantIndex !== -1) {
+ // Update existing participant
+ meeting.participants[existingParticipantIndex] = {
+ id: participantId,
+ name: participantName,
+ isHost: isHost,
+ platform: platform,
+ joinTime: new Date().toISOString(),
+ status: 'active'
+ };
+ } else {
+ // Add new participant
+ meeting.participants.push({
+ id: participantId,
+ name: participantName,
+ isHost: isHost,
+ platform: platform,
+ joinTime: new Date().toISOString(),
+ status: 'active'
+ });
+ }
+
+ console.log(`Added/updated participant data for meeting: ${noteId}`);
+
+ // Notify the renderer if this note is currently being edited
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send('participants-updated', noteId);
+ }
+
+ // Return the updated data to be written
+ return meetingsData;
+ });
+
+ console.log(`Processed participant join event for meeting: ${noteId}`);
+ } catch (error) {
+ console.error('Error processing participant join event:', error);
+ }
+}
+
+// Tracks the currently active speaker as detected by the SDK's participant updates
+let currentActiveSpeaker = null;
+
+async function processTranscriptProviderData(evt) {
+ // provider_data from AssemblyAI streaming only contains WebSocket handshake
+ // messages, not actual transcript data with speaker IDs — intentionally ignored
+}
+
+// Function to process transcript data and store it with the meeting note
+async function processTranscriptData(evt) {
+ try {
+ const windowId = evt.window?.id;
+ if (!windowId) {
+ console.error("Missing window ID in transcript event");
+ return;
+ }
+
+ // Check if we have this meeting in our active meetings
+ if (!global.activeMeetingIds || !global.activeMeetingIds[windowId]) {
+ console.log(`No active meeting found for window ID: ${windowId}`);
+ return;
+ }
+
+ const noteId = global.activeMeetingIds[windowId].noteId;
+ if (!noteId) {
+ console.log(`No note ID found for window ID: ${windowId}`);
+ return;
+ }
+
+ const words = evt.data.data.words || [];
+ if (words.length === 0) {
+ return;
+ }
+
+ // The SDK's transcript.data always attributes speech to the host on Google Meet.
+ // Use the active speaker tracked from SDK's internal participant updates instead.
+ const speaker = currentActiveSpeaker || evt.data.data.participant?.name || "Unknown Speaker";
+ console.log(`[speaker-debug] Using active speaker: ${speaker} (currentActiveSpeaker=${currentActiveSpeaker}, transcript.participant=${evt.data.data.participant?.name})`);
+
+ const text = words.map(word => word.text).join(" ");
+ console.log(`Transcript from ${speaker}: "${text}"`);
+
+ await fileOperationManager.scheduleOperation(async (meetingsData) => {
+ const noteIndex = meetingsData.pastMeetings.findIndex(meeting => meeting.id === noteId);
+ if (noteIndex === -1) {
+ console.log(`No meeting note found with ID: ${noteId}`);
+ return null;
+ }
+
+ const meeting = meetingsData.pastMeetings[noteIndex];
+
+ if (!meeting.transcript) {
+ meeting.transcript = [];
+ }
+
+ // Store in the same format as the AssemblyAI transcript file so the
+ // backend can upload it directly with correct speakers + timestamps
+ meeting.transcript.push({
+ participant: { name: speaker },
+ words: words.map(word => ({
+ text: word.text,
+ start_timestamp: word.start_timestamp || undefined,
+ end_timestamp: word.end_timestamp || undefined,
+ })),
+ });
+
+ console.log(`Added transcript data for meeting: ${noteId}`);
+
+ // Notify the renderer if this note is currently being edited
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send('transcript-updated', noteId);
+ }
+
+ // Return the updated data to be written
+ return meetingsData;
+ });
+
+ console.log(`Processed transcript data for meeting: ${noteId}`);
+ } catch (error) {
+ console.error('Error processing transcript data:', error);
+ }
+}
+
+// Function to generate AI summary from transcript with streaming support
+async function generateMeetingSummary(meeting, progressCallback = null) {
+ try {
+ if (!process.env.OPENROUTER_KEY || process.env.OPENROUTER_KEY === 'open_api_key') {
+ console.log('Skipping AI summary: no valid OPENROUTER_KEY configured in .env');
+ return 'AI summary unavailable — set a valid OPENROUTER_KEY in your .env file to enable this feature.';
+ }
+
+ if (!meeting.transcript || meeting.transcript.length === 0) {
+ console.log('No transcript available to summarize');
+ return 'No transcript available to summarize.';
+ }
+
+ console.log(`Generating AI summary for meeting: ${meeting.id}`);
+
+ // Format the transcript into a single text for the AI to process
+ const transcriptText = meeting.transcript.map(entry =>
+ `${entry.speaker}: ${entry.text}`
+ ).join('\n');
+
+ // Format detected participants if available
+ let participantsText = "";
+ if (meeting.participants && meeting.participants.length > 0) {
+ participantsText = "Detected participants:\n" + meeting.participants.map(p =>
+ `- ${p.name}${p.isHost ? ' (Host)' : ''}`
+ ).join('\n');
+ }
+
+ // Define a system prompt to guide the AI's response with a specific format
+ const systemMessage =
+ "You are an AI assistant that summarizes meeting transcripts. " +
+ "You MUST format your response using the following structure:\n\n" +
+ "# Participants\n" +
+ "- [List all participants mentioned in the transcript]\n\n" +
+ "# Summary\n" +
+ "- [Key discussion point 1]\n" +
+ "- [Key discussion point 2]\n" +
+ "- [Key decisions made]\n" +
+ "- [Include any important deadlines or dates mentioned]\n\n" +
+ "# Action Items\n" +
+ "- [Action item 1] - [Responsible person if mentioned]\n" +
+ "- [Action item 2] - [Responsible person if mentioned]\n" +
+ "- [Add any other action items discussed]\n\n" +
+ "Stick strictly to this format with these exact section headers. Keep each bullet point concise but informative.";
+
+ // Prepare the messages array for the API
+ const messages = [
+ { role: "system", content: systemMessage },
+ {
+ role: "user", content: `Summarize the following meeting transcript with the EXACT format specified in your instructions:
+${participantsText ? participantsText + "\n\n" : ""}
+Transcript:
+${transcriptText}`
+ }
+ ];
+
+ // If no progress callback provided, use the non-streaming version
+ if (!progressCallback) {
+ const response = await getOpenAIClient().chat.completions.create({
+ model: MODELS.PRIMARY, // Use our primary model for a good balance of quality and speed
+ messages: messages,
+ max_tokens: 1000,
+ temperature: 0.7,
+ fallbacks: MODELS.FALLBACKS, // Use our defined fallback models
+ transform_to_openai: true, // Ensures consistent response format across models
+ route: "fallback" // Automatically use fallbacks if the primary model is unavailable
+ });
+
+ // Log which model was actually used
+ console.log(`AI summary generated successfully using model: ${response.model}`);
+
+ // Return the generated summary
+ return response.choices[0].message.content;
+ } else {
+ // Use streaming version and accumulate the response
+ let fullText = '';
+
+ const stream = await getOpenAIClient().chat.completions.create({
+ model: MODELS.PRIMARY, // Use our primary model for a good balance of quality and speed
+ messages: messages,
+ max_tokens: 1000,
+ temperature: 0.7,
+ stream: true,
+ fallbacks: MODELS.FALLBACKS, // Use our defined fallback models
+ transform_to_openai: true, // Ensures consistent response format across models
+ route: "fallback" // Automatically use fallbacks if the primary model is unavailable
+ });
+
+ // Handle streaming events
+ return new Promise((resolve, reject) => {
+ // Process the stream
+ (async () => {
+ try {
+ // Log the model being used when first chunk arrives (if available)
+ let modelLogged = false;
+
+ for await (const chunk of stream) {
+ // Log the model on first chunk if available
+ if (!modelLogged && chunk.model) {
+ console.log(`Streaming with model: ${chunk.model}`);
+ modelLogged = true;
+ }
+
+ // Extract the text content from the chunk
+ const content = chunk.choices[0]?.delta?.content || '';
+
+ if (content) {
+ // Add the new text chunk to our accumulated text
+ fullText += content;
+
+ // Log each token for debugging (less verbose)
+ if (content.length < 50) {
+ console.log(`Received token: "${content}"`);
+ } else {
+ console.log(`Received content of length: ${content.length}`);
+ }
+
+ // Call the progress callback immediately with each token
+ if (progressCallback) {
+ progressCallback(fullText);
+ }
+ }
+ }
+
+ console.log('AI summary streaming completed');
+ resolve(fullText);
+ } catch (error) {
+ console.error('Stream error:', error);
+ reject(error);
+ }
+ })();
+ });
+ }
+ } catch (error) {
+ console.error('Error generating meeting summary:', error);
+
+ // Check if it's an OpenRouter/OpenAI specific error
+ if (error.status) {
+ return `Error generating summary: API returned status ${error.status}: ${error.message}`;
+ } else if (error.response) {
+ // Handle errors with a response object
+ return `Error generating summary: ${error.response.status} - ${error.response.data?.error?.message || error.message}`;
+ } else {
+ // Default error handling
+ return `Error generating summary: ${error.message}`;
+ }
+ }
+}
+
+// Function to update a note with recording information when recording ends
+async function updateNoteWithRecordingInfo(recordingId) {
+ try {
+ // Read the current meetings data
+ let meetingsData;
+ try {
+ const fileData = await fs.promises.readFile(meetingsFilePath, 'utf8');
+ meetingsData = JSON.parse(fileData);
+ } catch (error) {
+ console.error('Error reading meetings data:', error);
+ return;
+ }
+
+ // Find the meeting note with this recording ID
+ const noteIndex = meetingsData.pastMeetings.findIndex(meeting =>
+ meeting.recordingId === recordingId
+ );
+
+ if (noteIndex === -1) {
+ console.log('No meeting note found for recording ID:', recordingId);
+ return;
+ }
+
+ // Format current date
+ const now = new Date();
+ const formattedDate = now.toLocaleString();
+
+ // Update the meeting note content
+ const meeting = meetingsData.pastMeetings[noteIndex];
+ const content = meeting.content;
+
+ // Replace the "Recording: In Progress..." line with completed information
+ let updatedContent = content.replace(
+ "Recording: In Progress...",
+ `Recording: Completed at ${formattedDate}\n`
+ );
+
+ // Update the meeting object
+ meeting.content = updatedContent;
+ meeting.recordingComplete = true;
+ meeting.recordingEndTime = now.toISOString();
+
+ // Save the initial update
+ await fileOperationManager.writeData(meetingsData);
+
+ // Build the Recall link footer
+ const recallLink = meeting.recallUrl
+ ? `\n\n---\nRecording: ${meeting.recallUrl}`
+ : '';
+
+ // Generate AI summary if there's a transcript
+ if (meeting.transcript && meeting.transcript.length > 0) {
+ console.log(`Generating AI summary for meeting ${meeting.id}...`);
+
+ // Log summary generation to console instead of showing a notification
+ console.log('Generating AI summary for meeting: ' + meeting.id);
+
+ // Get meeting title for use in the new content
+ const meetingTitle = meeting.title || "Meeting Notes";
+
+ // Create initial content with placeholder
+ meeting.content = `# ${meetingTitle}\nGenerating summary...`;
+
+ // Notify any open editors immediately
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send('summary-update', {
+ meetingId: meeting.id,
+ content: meeting.content
+ });
+ }
+
+ // Create progress callback for streaming updates
+ const streamProgress = (currentText) => {
+ // Update content with current streaming text
+ meeting.content = `# ${meetingTitle}\n\n${currentText}${recallLink}`;
+
+ // Send immediate update to renderer if note is open
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ try {
+ mainWindow.webContents.send('summary-update', {
+ meetingId: meeting.id,
+ content: meeting.content,
+ timestamp: Date.now()
+ });
+ } catch (err) {
+ console.error('Error sending streaming update to renderer:', err);
+ }
+ }
+ };
+
+ // Generate the summary with streaming updates
+ const summary = await generateMeetingSummary(meeting, streamProgress);
+
+ // Set the content with summary and Recall link
+ meeting.content = `${summary}${recallLink}`;
+
+ meeting.hasSummary = true;
+
+ // Save the updated data with summary
+ await fileOperationManager.writeData(meetingsData);
+
+ console.log('Updated meeting note with AI summary');
+ } else {
+ meeting.content = updatedContent + recallLink;
+ await fileOperationManager.writeData(meetingsData);
+ }
+
+ // If the note is currently open, notify the renderer to refresh it
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send('recording-completed', meeting.id);
+ }
+ } catch (error) {
+ console.error('Error updating note with recording info:', error);
+ }
+}
+
+ipcMain.handle('openExternal', async (event, url) => {
+ if (url && (url.startsWith('https://') || url.startsWith('http://'))) {
+ await shell.openExternal(url);
+ }
+});
+
+ipcMain.handle('getRecordingVideoUrl', async (event, recallRecordingId) => {
+ try {
+ const response = await axios.get(`http://localhost:13373/recording/${recallRecordingId}`, { timeout: 15000 });
+ if (response.data.status === 'success' && response.data.video_url) {
+ return { success: true, videoUrl: response.data.video_url, transcriptUrl: response.data.transcript_url };
+ }
+ return { success: false, error: response.data.message || 'Recording not ready yet' };
+ } catch (error) {
+ console.error('Error fetching recording video URL:', error.message);
+ return { success: false, error: error.message };
+ }
+});
+
+// Function to check if there's a detected meeting available
+ipcMain.handle('checkForDetectedMeeting', async () => {
+ return detectedMeeting !== null;
+});
+
+// Function to join the detected meeting
+ipcMain.handle('joinDetectedMeeting', async () => {
+ return joinDetectedMeeting();
+});
+
+// Function to handle joining a detected meeting
+async function joinDetectedMeeting() {
+ try {
+ console.log("Join detected meeting called");
+
+ if (!detectedMeeting) {
+ console.log("No detected meeting available");
+ return { success: false, error: "No active meeting detected" };
+ }
+
+ // Map platform codes to readable names
+ const platformNames = {
+ 'zoom': 'Zoom',
+ 'google-meet': 'Google Meet',
+ 'slack': 'Slack',
+ 'teams': 'Microsoft Teams'
+ };
+
+ // Get a user-friendly platform name, or use the raw platform name if not in our map
+ const platformName = platformNames[detectedMeeting.window.platform] || detectedMeeting.window.platform;
+
+ console.log("Joining detected meeting for platform:", platformName);
+
+ // Ensure main window exists and is visible
+ if (!mainWindow || mainWindow.isDestroyed()) {
+ console.log("Creating new main window");
+ createWindow();
+ }
+
+ // Bring window to front with focus
+ if (mainWindow.isMinimized()) mainWindow.restore();
+ mainWindow.show();
+ mainWindow.focus();
+
+ // Process with more reliable timing
+ return new Promise((resolve) => {
+ // Wait a moment for the window to be fully focused and ready
+ setTimeout(async () => {
+ console.log("Window is ready, creating new meeting note");
+
+ try {
+ // Create a new meeting note and start recording
+ const id = await createMeetingNoteAndRecord(platformName);
+
+ console.log("Created new meeting with ID:", id);
+ resolve({ success: true, meetingId: id });
+ } catch (err) {
+ console.error("Error creating meeting note:", err);
+ resolve({ success: false, error: err.message });
+ }
+ }, 800); // Increased timeout for more reliability
+ });
+ } catch (error) {
+ console.error("Error in joinDetectedMeeting:", error);
+ return { success: false, error: error.message };
+ }
+}
diff --git a/packages/twenty-companion/src/pages/note-editor/index.html b/packages/twenty-companion/src/pages/note-editor/index.html
new file mode 100644
index 0000000000..0f1f038d47
--- /dev/null
+++ b/packages/twenty-companion/src/pages/note-editor/index.html
@@ -0,0 +1,149 @@
+
+
+
+
+ Twenty - Note Editor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Paint shopping plan
+
+
+
+ Apr 24
+
+
+
+ Me
+
+
+
+
+# Meeting Title
+* Paint shopping plan
+
+# Meeting Date and Time
+* April 24, 2025, 22:36:12
+
+# Participants
+* Nick Faro
+
+# Description
+* The meeting involved Nick Faro discussing plans to purchase a can of paint. The conversation was brief and included a humorous remark about going to Mars. There were no specific action items or further details discussed during the meeting.
+
+Chat with meeting transcript: https://notes.granola.ai/d/393a95cf-4788-4a47-9deb-3952c665a56b
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/twenty-companion/src/pages/note-editor/renderer.js b/packages/twenty-companion/src/pages/note-editor/renderer.js
new file mode 100644
index 0000000000..a0980a5aa7
--- /dev/null
+++ b/packages/twenty-companion/src/pages/note-editor/renderer.js
@@ -0,0 +1,79 @@
+// Import styles
+import './styles.css';
+
+// Initialize the markdown editor when the DOM is loaded
+document.addEventListener('DOMContentLoaded', () => {
+ // Initialize SimpleMDE Markdown Editor
+ const editor = new SimpleMDE({
+ element: document.getElementById('editor'),
+ spellChecker: false,
+ autofocus: true,
+ status: false,
+ toolbar: [
+ 'bold', 'italic', 'heading', '|',
+ 'quote', 'unordered-list', 'ordered-list', '|',
+ 'link', 'image', '|',
+ 'preview', 'side-by-side', 'fullscreen',
+ ],
+ placeholder: 'Write your notes here...',
+ initialValue: document.getElementById('editor').textContent.trim(),
+ });
+
+ // Handle sidebar toggle
+ const toggleSidebarBtn = document.getElementById('toggleSidebar');
+ const sidebar = document.getElementById('sidebar');
+ const editorContent = document.querySelector('.editor-content');
+
+ toggleSidebarBtn.addEventListener('click', () => {
+ sidebar.classList.toggle('hidden');
+ editorContent.classList.toggle('full-width');
+ });
+
+ // Handle back button
+ const backButton = document.getElementById('backButton');
+ backButton.addEventListener('click', () => {
+ window.electronAPI.navigate('home');
+ });
+
+ // Chat input handling
+ const chatInput = document.getElementById('chatInput');
+ const sendButton = document.getElementById('sendButton');
+
+ // When send button is clicked
+ sendButton.addEventListener('click', () => {
+ const message = chatInput.value.trim();
+ if (message) {
+ console.log('Sending message:', message);
+ // Here you would handle the AI chat functionality
+ // For now, just clear the input
+ chatInput.value = '';
+ }
+ });
+
+ // Send message on Enter key
+ chatInput.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') {
+ sendButton.click();
+ }
+ });
+
+ // Handle share buttons
+ const shareButtons = document.querySelectorAll('.share-btn');
+ shareButtons.forEach(button => {
+ button.addEventListener('click', () => {
+ const action = button.textContent.trim();
+ console.log(`Share action: ${action}`);
+ // Implement actual sharing functionality here
+ });
+ });
+
+ // Handle AI option buttons
+ const aiButtons = document.querySelectorAll('.ai-btn');
+ aiButtons.forEach(button => {
+ button.addEventListener('click', () => {
+ const action = button.textContent.trim();
+ console.log(`AI action: ${action}`);
+ // Implement actual AI functionality here
+ });
+ });
+});
\ No newline at end of file
diff --git a/packages/twenty-companion/src/pages/note-editor/styles.css b/packages/twenty-companion/src/pages/note-editor/styles.css
new file mode 100644
index 0000000000..a5eeedbe77
--- /dev/null
+++ b/packages/twenty-companion/src/pages/note-editor/styles.css
@@ -0,0 +1,356 @@
+:root {
+ --primary-bg: #f9f9f9;
+ --card-bg: #fff;
+ --light-purple: #f3e9ff;
+ --light-green: #e8f5e9;
+ --border-color: #e0e0e0;
+ --text-primary: #000;
+ --text-secondary: #666;
+ --sidebar-width: 320px;
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+ background-color: var(--primary-bg);
+ margin: 0;
+ overflow-x: hidden;
+ color: var(--text-primary);
+}
+
+.app-container {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+}
+
+/* Header Styles */
+.header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 20px;
+ background-color: var(--card-bg);
+ border-bottom: 1px solid var(--border-color);
+ position: sticky;
+ top: 0;
+ z-index: 100;
+}
+
+#drag-region {
+ -webkit-app-region: drag;
+}
+
+.header-left, .header-right {
+ display: flex;
+ align-items: center;
+}
+
+.header-left {
+ margin-left: 60px;
+ gap: 8px;
+}
+
+.app-logo {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 6px;
+ overflow: hidden;
+ flex-shrink: 0;
+}
+
+.header-center {
+ flex-grow: 1;
+ max-width: 400px;
+ margin: 0 20px;
+}
+
+.search-container {
+ position: relative;
+ display: flex;
+ align-items: center;
+}
+
+.search-icon {
+ position: absolute;
+ left: 10px;
+ color: var(--text-secondary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.search-input {
+ width: 100%;
+ padding: 8px 8px 8px 35px;
+ border-radius: 5px;
+ border: 1px solid var(--border-color);
+ background-color: #f2f2f2;
+ font-size: 14px;
+ -webkit-app-region: no-drag;
+}
+
+.btn {
+ padding: 8px 16px;
+ border-radius: 5px;
+ border: none;
+ cursor: pointer;
+ font-weight: 500;
+ -webkit-app-region: no-drag;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.back-btn {
+ background-color: transparent;
+ color: var(--text-secondary);
+ padding: 8px;
+}
+
+.toggle-sidebar-btn {
+ background-color: transparent;
+ color: var(--text-secondary);
+ padding: 8px;
+ margin-right: 10px;
+ display: none;
+}
+
+.user-avatar {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ overflow: hidden;
+ background-color: #ddd;
+ -webkit-app-region: no-drag;
+}
+
+/* Note Editor Layout */
+.note-container {
+ display: flex;
+ flex: 1;
+ position: relative;
+}
+
+.editor-content {
+ flex: 1;
+ padding: 40px;
+ background-color: var(--card-bg);
+ max-width: calc(100% - var(--sidebar-width));
+ transition: max-width 0.3s ease;
+}
+
+.editor-content.full-width {
+ max-width: 100%;
+}
+
+.note-header {
+ margin-bottom: 30px;
+}
+
+.note-title {
+ font-size: 28px;
+ font-weight: 600;
+ margin-bottom: 10px;
+}
+
+.note-meta {
+ display: flex;
+ gap: 15px;
+ color: var(--text-secondary);
+ font-size: 14px;
+ margin-top: 10px;
+}
+
+.note-date, .note-author {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+
+/* Editor */
+.CodeMirror, .editor-preview {
+ font-size: 16px;
+ line-height: 1.6;
+}
+
+.CodeMirror {
+ border: none;
+ height: calc(100vh - 230px);
+}
+
+.editor-toolbar {
+ border: none;
+ opacity: 0.8;
+}
+
+/* Sidebar */
+.sidebar {
+ width: var(--sidebar-width);
+ background-color: var(--card-bg);
+ border-left: 1px solid var(--border-color);
+ padding: 20px;
+ overflow-y: auto;
+ transition: transform 0.3s ease;
+}
+
+.sidebar.hidden {
+ transform: translateX(var(--sidebar-width));
+}
+
+.sidebar-section {
+ margin-bottom: 30px;
+}
+
+.sidebar-section h3 {
+ font-size: 14px;
+ color: var(--text-secondary);
+ margin-bottom: 15px;
+ font-weight: 500;
+}
+
+.share-options, .share-export {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 15px;
+}
+
+.share-btn {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 16px;
+ border-radius: 5px;
+ border: 1px solid var(--border-color);
+ background-color: var(--card-bg);
+ color: var(--text-primary);
+ font-size: 14px;
+ cursor: pointer;
+ flex: 1;
+ justify-content: center;
+}
+
+.share-btn.wide {
+ flex: 1;
+}
+
+.ai-options {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.ai-btn {
+ padding: 10px 16px;
+ border-radius: 5px;
+ border: 1px solid var(--border-color);
+ background-color: var(--card-bg);
+ color: var(--text-primary);
+ font-size: 14px;
+ cursor: pointer;
+ text-align: left;
+}
+
+/* Chat Input */
+.chat-input {
+ display: flex;
+ margin: 20px auto;
+ width: 90%;
+ max-width: 800px;
+ position: relative;
+ bottom: 20px;
+}
+
+#chatInput {
+ flex: 1;
+ padding: 12px 20px;
+ border-radius: 20px;
+ border: 1px solid var(--border-color);
+ font-size: 14px;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+}
+
+#sendButton {
+ position: absolute;
+ right: 10px;
+ top: 50%;
+ transform: translateY(-50%);
+ background: transparent;
+ border: none;
+ cursor: pointer;
+}
+
+/* SimpleMDE customizations */
+.CodeMirror-fullscreen, .editor-preview-side {
+ z-index: 999;
+}
+
+.editor-toolbar.fullscreen {
+ z-index: 1000;
+}
+
+/* Custom markdown rendering */
+.editor-preview h1, .editor-preview-side h1 {
+ font-size: 22px;
+ color: #333;
+ margin: 25px 0 15px 0;
+}
+
+.editor-preview h2, .editor-preview-side h2 {
+ font-size: 18px;
+ color: #444;
+ margin: 20px 0 10px 0;
+}
+
+.editor-preview ul, .editor-preview-side ul {
+ padding-left: 20px;
+ margin: 10px 0;
+}
+
+.editor-preview a, .editor-preview-side a {
+ color: #0077ff;
+ text-decoration: none;
+}
+
+.editor-preview a:hover, .editor-preview-side a:hover {
+ text-decoration: underline;
+}
+
+/* Make code look nice */
+.editor-preview code, .editor-preview-side code {
+ background-color: #f4f4f4;
+ padding: 2px 4px;
+ border-radius: 3px;
+ font-family: monospace;
+}
+
+/* Mobile adjustments */
+@media (max-width: 768px) {
+ .editor-content {
+ padding: 20px;
+ }
+
+ .sidebar {
+ width: 100%;
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 200;
+ transform: translateX(100%);
+ }
+
+ .sidebar.hidden {
+ transform: translateX(0);
+ }
+
+ .editor-content {
+ max-width: 100%;
+ }
+}
diff --git a/packages/twenty-companion/src/preload.js b/packages/twenty-companion/src/preload.js
new file mode 100644
index 0000000000..bbd556782d
--- /dev/null
+++ b/packages/twenty-companion/src/preload.js
@@ -0,0 +1,40 @@
+// See the Electron documentation for details on how to use preload scripts:
+// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
+
+const { contextBridge, ipcRenderer } = require('electron');
+
+// Set up the SDK logger bridge between main and renderer
+contextBridge.exposeInMainWorld('sdkLoggerBridge', {
+ // Receive logs from main process
+ onSdkLog: (callback) => ipcRenderer.on('sdk-log', (_, logEntry) => callback(logEntry)),
+
+ // Send logs from renderer to main process
+ sendSdkLog: (logEntry) => ipcRenderer.send('sdk-log', logEntry)
+});
+
+contextBridge.exposeInMainWorld('electronAPI', {
+ navigate: (page) => ipcRenderer.send('navigate', page),
+ saveMeetingsData: (data) => ipcRenderer.invoke('saveMeetingsData', data),
+ loadMeetingsData: () => ipcRenderer.invoke('loadMeetingsData'),
+ deleteMeeting: (meetingId) => ipcRenderer.invoke('deleteMeeting', meetingId),
+ generateMeetingSummary: (meetingId) => ipcRenderer.invoke('generateMeetingSummary', meetingId),
+ generateMeetingSummaryStreaming: (meetingId) => ipcRenderer.invoke('generateMeetingSummaryStreaming', meetingId),
+ startManualRecording: (meetingId) => ipcRenderer.invoke('startManualRecording', meetingId),
+ stopManualRecording: (recordingId) => ipcRenderer.invoke('stopManualRecording', recordingId),
+ debugGetHandlers: () => ipcRenderer.invoke('debugGetHandlers'),
+ checkForDetectedMeeting: () => ipcRenderer.invoke('checkForDetectedMeeting'),
+ joinDetectedMeeting: () => ipcRenderer.invoke('joinDetectedMeeting'),
+ onOpenMeetingNote: (callback) => ipcRenderer.on('open-meeting-note', (_, meetingId) => callback(meetingId)),
+ onRecordingCompleted: (callback) => ipcRenderer.on('recording-completed', (_, meetingId) => callback(meetingId)),
+ onTranscriptUpdated: (callback) => ipcRenderer.on('transcript-updated', (_, meetingId) => callback(meetingId)),
+ onSummaryGenerated: (callback) => ipcRenderer.on('summary-generated', (_, meetingId) => callback(meetingId)),
+ onSummaryUpdate: (callback) => ipcRenderer.on('summary-update', (_, data) => callback(data)),
+ onRecordingStateChange: (callback) => ipcRenderer.on('recording-state-change', (_, data) => callback(data)),
+ onParticipantsUpdated: (callback) => ipcRenderer.on('participants-updated', (_, meetingId) => callback(meetingId)),
+ onVideoFrame: (callback) => ipcRenderer.on('video-frame', (_, data) => callback(data)),
+ onMeetingDetectionStatus: (callback) => ipcRenderer.on('meeting-detection-status', (_, data) => callback(data)),
+ onMeetingTitleUpdated: (callback) => ipcRenderer.on('meeting-title-updated', (_, data) => callback(data)),
+ getActiveRecordingId: (noteId) => ipcRenderer.invoke('getActiveRecordingId', noteId),
+ openExternal: (url) => ipcRenderer.invoke('openExternal', url),
+ getRecordingVideoUrl: (recordingId) => ipcRenderer.invoke('getRecordingVideoUrl', recordingId)
+});
diff --git a/packages/twenty-companion/src/renderer.js b/packages/twenty-companion/src/renderer.js
new file mode 100644
index 0000000000..72505d7c1c
--- /dev/null
+++ b/packages/twenty-companion/src/renderer.js
@@ -0,0 +1,2059 @@
+/**
+ * This file will automatically be loaded by webpack and run in the "renderer" context.
+ * To learn more about the differences between the "main" and the "renderer" context in
+ * Electron, visit:
+ *
+ * https://electronjs.org/docs/tutorial/process-model
+ */
+
+import './index.css';
+
+// Create empty meetings data structure to be filled from the file
+const meetingsData = {
+ upcomingMeetings: [],
+ pastMeetings: []
+};
+
+// Create empty arrays that will be filled from file
+const upcomingMeetings = [];
+const pastMeetings = [];
+
+// Group past meetings by date
+let pastMeetingsByDate = {};
+
+// Global recording state variables
+window.isRecording = false;
+window.currentRecordingId = null;
+
+
+// Function to check if there's an active recording for the current note
+async function checkActiveRecordingState() {
+ if (!currentEditingMeetingId) return;
+
+ try {
+ console.log('Checking active recording state for note:', currentEditingMeetingId);
+ const result = await window.electronAPI.getActiveRecordingId(currentEditingMeetingId);
+
+ if (result.success && result.data) {
+ console.log('Found active recording for current note:', result.data);
+ updateRecordingButtonUI(true, result.data.recordingId);
+ } else {
+ console.log('No active recording found for note');
+ updateRecordingButtonUI(false, null);
+ }
+ } catch (error) {
+ console.error('Error checking recording state:', error);
+ }
+}
+
+// Function to update the recording button UI
+function updateRecordingButtonUI(isActive, recordingId) {
+ const recordButton = document.getElementById('recordButton');
+ if (!recordButton) return;
+
+ // Get the elements inside the button
+ const recordIcon = recordButton.querySelector('.record-icon');
+ const stopIcon = recordButton.querySelector('.stop-icon');
+
+ if (isActive) {
+ // Recording is active
+ console.log('Updating UI for active recording:', recordingId);
+ window.isRecording = true;
+ window.currentRecordingId = recordingId;
+
+ // Update button UI
+ recordButton.classList.add('recording');
+ recordIcon.style.display = 'none';
+ stopIcon.style.display = 'block';
+ } else {
+ // No active recording
+ console.log('Updating UI for inactive recording');
+ window.isRecording = false;
+ window.currentRecordingId = null;
+
+ // Update button UI
+ recordButton.classList.remove('recording');
+ recordIcon.style.display = 'block';
+ stopIcon.style.display = 'none';
+ }
+}
+
+// Function to format date for section headers
+function formatDateHeader(dateString) {
+ const date = new Date(dateString);
+ const now = new Date();
+ const yesterday = new Date(now);
+ yesterday.setDate(yesterday.getDate() - 1);
+
+ // Check if date is today, yesterday, or earlier
+ if (date.toDateString() === now.toDateString()) {
+ return 'Today';
+ } else if (date.toDateString() === yesterday.toDateString()) {
+ return 'Yesterday';
+ } else {
+ // Format as "Fri, Apr 25" or similar
+ const options = { weekday: 'short', month: 'short', day: 'numeric' };
+ return date.toLocaleDateString('en-US', options);
+ }
+}
+
+// We'll initialize pastMeetings and pastMeetingsByDate when we load data from file
+
+// Save meetings data back to file
+async function saveMeetingsData() {
+ // Save to localStorage as a backup
+ localStorage.setItem('meetingsData', JSON.stringify(meetingsData));
+
+ // Save to the actual file using IPC
+ try {
+ console.log('Saving meetings data to file...');
+ const result = await window.electronAPI.saveMeetingsData(meetingsData);
+ if (result.success) {
+ console.log('Meetings data saved successfully to file');
+ } else {
+ console.error('Failed to save meetings data to file:', result.error);
+ }
+ } catch (error) {
+ console.error('Error saving meetings data to file:', error);
+ }
+}
+
+// Keep track of which meeting is being edited
+let currentEditingMeetingId = null;
+
+// Function to save the current note
+async function saveCurrentNote() {
+ const editorElement = document.getElementById('simple-editor');
+ const noteTitleElement = document.getElementById('noteTitle');
+
+ // Early exit if elements aren't available
+ if (!editorElement || !noteTitleElement) {
+ console.warn('Cannot save note: Editor elements not found');
+ return;
+ }
+
+ // Early exit if no current meeting ID
+ if (!currentEditingMeetingId) {
+ console.warn('Cannot save note: No active meeting ID');
+ return;
+ }
+
+ // Get title text, defaulting to "New Note" if empty
+ const noteTitle = noteTitleElement.textContent.trim() || 'New Note';
+
+ // Set title back to element in case it was empty
+ if (!noteTitleElement.textContent.trim()) {
+ noteTitleElement.textContent = noteTitle;
+ }
+
+ // Find which meeting is currently active by ID
+ const activeMeeting = [...upcomingMeetings, ...pastMeetings].find(m => m.id === currentEditingMeetingId);
+
+ if (activeMeeting) {
+ console.log(`Saving note with ID: ${currentEditingMeetingId}, Title: ${noteTitle}`);
+
+ // Get the current content from the editor
+ const content = editorElement.value;
+ console.log(`Note content length: ${content.length} characters`);
+
+ // Update the title and content in the meeting object
+ activeMeeting.title = noteTitle;
+ activeMeeting.content = content;
+
+ // Update the data arrays directly to make sure they stay in sync
+ const pastIndex = meetingsData.pastMeetings.findIndex(m => m.id === currentEditingMeetingId);
+ if (pastIndex !== -1) {
+ meetingsData.pastMeetings[pastIndex].title = noteTitle;
+ meetingsData.pastMeetings[pastIndex].content = content;
+ console.log('Updated meeting in pastMeetings array');
+ }
+
+ const upcomingIndex = meetingsData.upcomingMeetings.findIndex(m => m.id === currentEditingMeetingId);
+ if (upcomingIndex !== -1) {
+ meetingsData.upcomingMeetings[upcomingIndex].title = noteTitle;
+ meetingsData.upcomingMeetings[upcomingIndex].content = content;
+ console.log('Updated meeting in upcomingMeetings array');
+ }
+
+ // Also update the subtitle if it's a date-based one
+ const dateObj = new Date(activeMeeting.date);
+ if (dateObj) {
+ document.getElementById('noteDate').textContent = formatDate(dateObj);
+ }
+
+ try {
+ // Save the data to file
+ await saveMeetingsData();
+ console.log('Note saved successfully:', noteTitle);
+ } catch (error) {
+ console.error('Error saving note:', error);
+ }
+ } else {
+ console.error(`Cannot save note: Meeting not found with ID: ${currentEditingMeetingId}`);
+
+ // Log all available meetings for debugging
+ console.log('Available meeting IDs:', [...upcomingMeetings, ...pastMeetings].map(m => m.id).join(', '));
+ }
+}
+
+// Format date for display in the note header
+function formatDate(date) {
+ const options = { month: 'short', day: 'numeric' };
+ return date.toLocaleDateString('en-US', options);
+}
+
+// Simple debounce function
+function debounce(func, wait) {
+ let timeout;
+ return function(...args) {
+ const context = this;
+ clearTimeout(timeout);
+ timeout = setTimeout(() => func.apply(context, args), wait);
+ };
+}
+
+
+
+// Function to create meeting card elements
+function createMeetingCard(meeting) {
+ const card = document.createElement('div');
+ card.className = 'meeting-card';
+ card.dataset.id = meeting.id;
+
+ let iconHtml = '';
+
+ if (meeting.type === 'profile') {
+ iconHtml = `
+
+ `;
+
+ // Add a highlight class for the newest entry
+ if (index === transcript.length - 1) {
+ entryDiv.classList.add('newest-entry');
+ }
+
+ transcriptDiv.appendChild(entryDiv);
+ });
+
+ transcriptContent.appendChild(transcriptDiv);
+
+ // Only auto-scroll to bottom if user was at the bottom before the update
+ if (wasAtBottom) {
+ // Use setTimeout to ensure DOM has updated
+ setTimeout(() => {
+ transcriptContent.scrollTop = transcriptContent.scrollHeight;
+ }, 0);
+ }
+}
+
+// Function to update the video preview in the debug panel
+function updateDebugVideoPreview(frameData) {
+ // Get the image data from the frame
+ const { buffer, participantId, participantName, frameType } = frameData;
+
+ // Determine if this is a screenshare or participant video
+ const isScreenshare = frameType !== 'webcam';
+
+ if (isScreenshare) {
+ updateScreensharePreview(frameData);
+ } else {
+ updateParticipantVideoPreview(frameData);
+ }
+
+ // Make sure debug panel toggle shows new content notification if panel is closed
+ const debugPanel = document.getElementById('debugPanel');
+ if (debugPanel && debugPanel.classList.contains('hidden')) {
+ const debugPanelToggle = document.getElementById('debugPanelToggle');
+ if (debugPanelToggle && !debugPanelToggle.classList.contains('has-new-content')) {
+ debugPanelToggle.classList.add('has-new-content');
+ }
+ }
+}
+
+// Function to update participant video preview
+function updateParticipantVideoPreview(frameData) {
+ const videoContent = document.getElementById('videoContent');
+ if (!videoContent) return;
+
+ const { buffer, participantId, participantName, frameType } = frameData;
+
+ // Check if we already have a container for this participant
+ let participantVideoContainer = document.getElementById(`video-participant-${participantId}`);
+
+ // If no container exists, create one
+ if (!participantVideoContainer) {
+ // Clear the placeholder content if this is the first frame
+ if (videoContent.querySelector('.placeholder-content')) {
+ videoContent.innerHTML = '';
+ }
+
+ // Create a container for this participant's video
+ participantVideoContainer = document.createElement('div');
+ participantVideoContainer.id = `video-participant-${participantId}`;
+ participantVideoContainer.className = 'video-participant-container';
+
+ // Add the name label
+ const nameLabel = document.createElement('div');
+ nameLabel.className = 'video-participant-name';
+ nameLabel.textContent = participantName;
+ participantVideoContainer.appendChild(nameLabel);
+
+ // Create an image element for the video frame
+ const videoImg = document.createElement('img');
+ videoImg.className = 'video-frame';
+ videoImg.id = `video-frame-${participantId}`;
+ participantVideoContainer.appendChild(videoImg);
+
+ // Add the frame type label
+ const typeLabel = document.createElement('div');
+ typeLabel.className = 'video-frame-type';
+ typeLabel.textContent = 'Camera';
+ participantVideoContainer.appendChild(typeLabel);
+
+ // Add to the video content area
+ videoContent.appendChild(participantVideoContainer);
+ }
+
+ // Update the image with the new frame
+ const videoImg = document.getElementById(`video-frame-${participantId}`);
+ if (videoImg) {
+ videoImg.src = `data:image/png;base64,${buffer}`;
+ }
+}
+
+// Function to update screenshare preview
+function updateScreensharePreview(frameData) {
+ const screenshareContent = document.getElementById('screenshareContent');
+ if (!screenshareContent) return;
+
+ const { buffer, participantId, participantName, frameType } = frameData;
+
+ // Check if we already have a container for this screenshare
+ let screenshareContainer = document.getElementById(`screenshare-participant-${participantId}`);
+
+ // If no container exists, create one
+ if (!screenshareContainer) {
+ // Clear the placeholder content if this is the first frame
+ if (screenshareContent.querySelector('.placeholder-content')) {
+ screenshareContent.innerHTML = '';
+ }
+
+ // Create a container for this participant's screenshare
+ screenshareContainer = document.createElement('div');
+ screenshareContainer.id = `screenshare-participant-${participantId}`;
+ screenshareContainer.className = 'video-participant-container';
+
+ // Create an image element for the screenshare frame
+ const screenshareImg = document.createElement('img');
+ screenshareImg.className = 'video-frame';
+ screenshareImg.id = `screenshare-frame-${participantId}`;
+ screenshareContainer.appendChild(screenshareImg);
+
+ // Add the frame type label
+ const typeLabel = document.createElement('div');
+ typeLabel.className = 'video-frame-type';
+ typeLabel.textContent = 'Screen';
+ screenshareContainer.appendChild(typeLabel);
+
+ // Add to the screenshare content area
+ screenshareContent.appendChild(screenshareContainer);
+ }
+
+ // Update the image with the new frame
+ const screenshareImg = document.getElementById(`screenshare-frame-${participantId}`);
+ if (screenshareImg) {
+ screenshareImg.src = `data:image/png;base64,${buffer}`;
+ }
+}
+
+// Function to update the participants section in the debug panel
+function updateDebugParticipants(participants) {
+ const participantsContent = document.getElementById('participantsContent');
+ if (!participantsContent) return;
+
+ // Clear previous content
+ participantsContent.innerHTML = '';
+
+ if (!participants || participants.length === 0) {
+ // Show placeholder if no participants are available
+ participantsContent.innerHTML = `
+