diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index aba0a7d4..f836ba22 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -3467,17 +3467,15 @@ function App() { const calendarEventJson = calEventMatch?.[1]?.replace(/''/g, "'") const { notes } = await window.ipc.invoke('meeting:summarize', { transcript: fileContent, meetingStartTime, calendarEventJson }) if (notes) { - // Prepend meeting notes below the title but above the transcript - const { raw: fm, body: transcriptBody } = splitFrontmatter(fileContent) - // Use frontmatter title as the heading (set from calendar event summary) + // Prepend meeting notes above the existing transcript block + const { raw: fm, body } = splitFrontmatter(fileContent) const fmTitleMatch = fileContent.match(/^title:\s*(.+)$/m) const noteTitle = fmTitleMatch?.[1]?.trim() || 'Meeting note' - // Strip any existing top-level heading from body - const bodyWithoutTitle = transcriptBody.replace(/^#\s+.+\s*\n*/, '') - // Also strip any title/heading the LLM may have generated const cleanedNotes = notes.replace(/^#{1,2}\s+.+\n+/, '') - const transcriptData = JSON.stringify({ transcript: bodyWithoutTitle.trim() }) - const newBody = `# ${noteTitle}\n\n` + cleanedNotes + '\n\n```transcript\n' + transcriptData + '\n```' + // Extract the existing transcript block and preserve it as-is + const transcriptBlockMatch = body.match(/(```transcript\n[\s\S]*?\n```)/) + const transcriptBlock = transcriptBlockMatch?.[1] || '' + const newBody = `# ${noteTitle}\n\n` + cleanedNotes + (transcriptBlock ? '\n\n' + transcriptBlock : '') const newContent = fm ? `${fm}\n${newBody}` : newBody await window.ipc.invoke('workspace:writeFile', { path: notePath, diff --git a/apps/x/apps/renderer/src/extensions/transcript-block.tsx b/apps/x/apps/renderer/src/extensions/transcript-block.tsx index 8893ac13..9b76f568 100644 --- a/apps/x/apps/renderer/src/extensions/transcript-block.tsx +++ b/apps/x/apps/renderer/src/extensions/transcript-block.tsx @@ -2,10 +2,54 @@ import { mergeAttributes, Node } from '@tiptap/react' import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' import { ChevronDown, FileText } from 'lucide-react' import { blocks } from '@x/shared' -import { useState } from 'react' +import { useState, useMemo } from 'react' -function TranscriptBlockView({ node }: { +interface TranscriptEntry { + speaker: string + text: string +} + +function parseTranscript(raw: string): TranscriptEntry[] { + const entries: TranscriptEntry[] = [] + const lines = raw.split('\n') + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed) continue + // Match **Speaker Name:** text or **You:** text + const match = trimmed.match(/^\*\*(.+?):\*\*\s*(.*)$/) + if (match) { + entries.push({ speaker: match[1], text: match[2] }) + } else if (entries.length > 0) { + // Continuation line — append to last entry + entries[entries.length - 1].text += ' ' + trimmed + } + } + return entries +} + +function speakerColor(speaker: string): string { + // Simple hash to pick a consistent color per speaker + let hash = 0 + for (let i = 0; i < speaker.length; i++) { + hash = speaker.charCodeAt(i) + ((hash << 5) - hash) + } + const colors = [ + '#3b82f6', // blue + '#06b6d4', // cyan + '#6366f1', // indigo + '#8b5cf6', // purple + '#0ea5e9', // sky + '#2563eb', // blue darker + '#7c3aed', // violet + ] + return colors[Math.abs(hash) % colors.length] +} + +function TranscriptBlockView({ node, getPos, editor }: { node: { attrs: Record } + getPos: () => number | undefined + // eslint-disable-next-line @typescript-eslint/no-explicit-any + editor: any }) { const raw = node.attrs.data as string let config: blocks.TranscriptBlock | null = null @@ -16,7 +60,27 @@ function TranscriptBlockView({ node }: { // fallback below } - const [expanded, setExpanded] = useState(false) + // Auto-detect: expand if this is the first real block (live recording), + // collapse if there's other content above (notes have been generated) + const isFirstBlock = useMemo(() => { + try { + const pos = getPos() + if (pos === undefined) return false + const firstChild = editor?.state?.doc?.firstChild + if (!firstChild) return true + // If the transcript block is right after the first node (heading), it's the main content + return pos <= (firstChild.nodeSize ?? 0) + 1 + } catch { + return false + } + }, [getPos, editor]) + + const [expanded, setExpanded] = useState(isFirstBlock) + + const entries = useMemo(() => { + if (!config) return [] + return parseTranscript(config.transcript) + }, [config]) if (!config) { return ( @@ -43,7 +107,18 @@ function TranscriptBlockView({ node }: { {expanded && (
- {config.transcript} + {entries.length > 0 ? ( + entries.map((entry, i) => ( +
+ + {entry.speaker} + + {entry.text} +
+ )) + ) : ( +
{config.transcript}
+ )}
)} diff --git a/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts b/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts index f45b83d9..1d114c14 100644 --- a/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts +++ b/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts @@ -89,13 +89,18 @@ function formatTranscript(entries: TranscriptEntry[], date: string, calendarEven `# ${noteTitle}`, '', ); + // Build the raw transcript text + const transcriptLines: string[] = []; for (let i = 0; i < entries.length; i++) { if (i > 0 && entries[i].speaker !== entries[i - 1].speaker) { - lines.push(''); + transcriptLines.push(''); } - lines.push(`**${entries[i].speaker}:** ${entries[i].text}`); - lines.push(''); + transcriptLines.push(`**${entries[i].speaker}:** ${entries[i].text}`); + transcriptLines.push(''); } + const transcriptText = transcriptLines.join('\n').trim(); + const transcriptData = JSON.stringify({ transcript: transcriptText }); + lines.push('```transcript', transcriptData, '```'); return lines.join('\n'); } diff --git a/apps/x/apps/renderer/src/styles/editor.css b/apps/x/apps/renderer/src/styles/editor.css index 1830cde2..efa481c1 100644 --- a/apps/x/apps/renderer/src/styles/editor.css +++ b/apps/x/apps/renderer/src/styles/editor.css @@ -1524,6 +1524,26 @@ margin-top: 10px; padding-top: 10px; border-top: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); + display: flex; + flex-direction: column; + gap: 6px; +} + +.tiptap-editor .ProseMirror .transcript-entry { + font-size: 13px; + line-height: 1.5; +} + +.tiptap-editor .ProseMirror .transcript-speaker { + font-weight: 600; + margin-right: 6px; +} + +.tiptap-editor .ProseMirror .transcript-text { + color: color-mix(in srgb, var(--foreground) 75%, transparent); +} + +.tiptap-editor .ProseMirror .transcript-raw { font-size: 13px; line-height: 1.6; color: color-mix(in srgb, var(--foreground) 70%, transparent);