diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 3ace4359..df4abcec 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -703,7 +703,7 @@ export function setupIpcHandlers() { return { success: false, error: 'Unknown format' }; }, 'meeting:summarize': async (_event, args) => { - const notes = await summarizeMeeting(args.transcript, args.meetingStartTime); + const notes = await summarizeMeeting(args.transcript, args.meetingStartTime, args.calendarEventJson); return { notes }; }, 'inline-task:classifySchedule': async (_event, args) => { diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index d02cd422..20fa5435 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -80,7 +80,7 @@ import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js' import { toast } from "sonner" import { useVoiceMode } from '@/hooks/useVoiceMode' import { useVoiceTTS } from '@/hooks/useVoiceTTS' -import { useMeetingTranscription, type MeetingTranscriptionState } from '@/hooks/useMeetingTranscription' +import { useMeetingTranscription, type MeetingTranscriptionState, type CalendarEventMeta } from '@/hooks/useMeetingTranscription' type DirEntry = z.infer type RunEventType = z.infer @@ -283,9 +283,12 @@ function sortNodes(nodes: TreeNode[]): TreeNode[] { } /** - * Flatten date-based folder hierarchy under Meetings/ source folders. - * Turns Meetings/granola/2026/03/15/Title.md into a flat list under - * Meetings/granola/ with display name "2026-03-15 Title". + * Organize Meetings/ source folders into date-grouped subfolders. + * + * - rowboat: rowboat/2026-03-20/meeting-xxx.md → keeps date folders as-is + * - granola: granola/2026/03/18/Title.md → collapses into "2026-03-18" folders + * - Files directly under a source folder (no date subfolder) are grouped + * by the date prefix in their filename (e.g. meeting-2026-03-17T...). */ function flattenMeetingsTree(nodes: TreeNode[]): TreeNode[] { return nodes.flatMap(node => { @@ -294,14 +297,18 @@ function flattenMeetingsTree(nodes: TreeNode[]): TreeNode[] { const flattenedSourceChildren = (node.children ?? []).flatMap(sourceNode => { if (sourceNode.kind !== 'dir') return [sourceNode] - // Collect all files recursively from the date hierarchy - const files: TreeNode[] = [] + // Collect all files with their date group label + const dateGroups = new Map() + function collectFiles(n: TreeNode, dateParts: string[]) { for (const child of n.children ?? []) { if (child.kind === 'file') { const dateStr = dateParts.join('-') - const displayName = dateStr ? `${dateStr} ${child.name}` : child.name - files.push({ ...child, name: displayName }) + // If file is at root of source folder, try to extract date from filename + const groupKey = dateStr || extractDateFromFilename(child.name) || 'other' + const group = dateGroups.get(groupKey) ?? [] + group.push(child) + dateGroups.set(groupKey, group) } else if (child.kind === 'dir') { collectFiles(child, [...dateParts, child.name]) } @@ -309,13 +316,24 @@ function flattenMeetingsTree(nodes: TreeNode[]): TreeNode[] { } collectFiles(sourceNode, []) - // Hide empty source folders - if (files.length === 0) return [] + if (dateGroups.size === 0) return [] - // Sort files reverse chronologically (newest first) - files.sort((a, b) => b.name.localeCompare(a.name)) + // Build date folder nodes, sorted reverse chronologically + const dateFolderNodes: TreeNode[] = [...dateGroups.entries()] + .sort(([a], [b]) => b.localeCompare(a)) + .map(([dateKey, files]) => { + // Sort files within each date group reverse chronologically + files.sort((a, b) => b.name.localeCompare(a.name)) + return { + name: dateKey, + path: `${sourceNode.path}/${dateKey}`, + kind: 'dir' as const, + children: files, + loaded: true, + } + }) - return [{ ...sourceNode, children: files }] + return [{ ...sourceNode, children: dateFolderNodes }] }) // Hide Meetings folder entirely if no source folders have files @@ -325,6 +343,12 @@ function flattenMeetingsTree(nodes: TreeNode[]): TreeNode[] { }) } +/** Extract YYYY-MM-DD from filenames like "meeting-2026-03-17T05-01-47.md" */ +function extractDateFromFilename(name: string): string | null { + const match = name.match(/(\d{4}-\d{2}-\d{2})/) + return match ? match[1] : null +} + // Build tree structure from flat entries function buildTree(entries: DirEntry[]): TreeNode[] { const treeMap = new Map() @@ -3362,13 +3386,16 @@ function App() { }, [loadDirectory, navigateToFile, fileTabs]) const meetingNotePathRef = useRef(null) + const pendingCalendarEventRef = useRef(undefined) const [meetingSummarizing, setMeetingSummarizing] = useState(false) const [showMeetingPermissions, setShowMeetingPermissions] = useState(false) const startMeetingAfterPermissions = useCallback(async () => { setShowMeetingPermissions(false) localStorage.setItem('meeting-permissions-acknowledged', '1') - const notePath = await meetingTranscription.start() + const calEvent = pendingCalendarEventRef.current + pendingCalendarEventRef.current = undefined + const notePath = await meetingTranscription.start(calEvent) if (notePath) { meetingNotePathRef.current = notePath await handleVoiceNoteCreated(notePath) @@ -3387,16 +3414,25 @@ function App() { const result = await window.ipc.invoke('workspace:readFile', { path: notePath, encoding: 'utf8' }) const fileContent = result.data if (fileContent && fileContent.trim()) { - // Extract meeting start time from frontmatter for calendar matching + // Extract meeting start time and calendar event from frontmatter const dateMatch = fileContent.match(/^date:\s*"(.+)"$/m) const meetingStartTime = dateMatch?.[1] - const { notes } = await window.ipc.invoke('meeting:summarize', { transcript: fileContent, meetingStartTime }) + // If a calendar event was linked, pass it directly so the summarizer + // skips scanning and uses this event for attendee/title info. + const calEventMatch = fileContent.match(/^calendar_event:\s*'(.+)'$/m) + 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) - // Strip the "# Meeting note" title from transcript body — we'll put it first - const bodyWithoutTitle = transcriptBody.replace(/^#\s+Meeting note\s*\n*/, '') - const newBody = '# Meeting note\n\n' + notes + '\n\n---\n\n## Raw transcript\n\n' + bodyWithoutTitle + // Use frontmatter title as the heading (set from calendar event summary) + 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 newBody = `# ${noteTitle}\n\n` + cleanedNotes + '\n\n---\n\n## Raw transcript\n\n' + bodyWithoutTitle const newContent = fm ? `${fm}\n${newBody}` : newBody await window.ipc.invoke('workspace:writeFile', { path: notePath, @@ -3419,7 +3455,9 @@ function App() { setShowMeetingPermissions(true) return } - const notePath = await meetingTranscription.start() + const calEvent = pendingCalendarEventRef.current + pendingCalendarEventRef.current = undefined + const notePath = await meetingTranscription.start(calEvent) if (notePath) { meetingNotePathRef.current = notePath await handleVoiceNoteCreated(notePath) @@ -3428,6 +3466,30 @@ function App() { }, [meetingTranscription, handleVoiceNoteCreated]) handleToggleMeetingRef.current = handleToggleMeeting + // Listen for calendar block "join meeting & take notes" events + useEffect(() => { + const handler = () => { + // Read calendar event data set by the calendar block on window + const pending = window.__pendingCalendarEvent + window.__pendingCalendarEvent = undefined + if (pending) { + pendingCalendarEventRef.current = { + summary: pending.summary, + start: pending.start, + end: pending.end, + location: pending.location, + htmlLink: pending.htmlLink, + conferenceLink: pending.conferenceLink, + source: pending.source, + } + } + // Use the same toggle flow — it will pick up pendingCalendarEventRef + handleToggleMeetingRef.current?.() + } + window.addEventListener('calendar-block:join-meeting', handler) + return () => window.removeEventListener('calendar-block:join-meeting', handler) + }, []) + const ensureWikiFile = useCallback(async (wikiPath: string) => { const resolvedPath = toKnowledgePath(wikiPath) if (!resolvedPath) return null diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index ac065e3d..4fb68c2f 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -13,8 +13,10 @@ import { ImageBlockExtension } from '@/extensions/image-block' import { EmbedBlockExtension } from '@/extensions/embed-block' import { ChartBlockExtension } from '@/extensions/chart-block' import { TableBlockExtension } from '@/extensions/table-block' +import { CalendarBlockExtension } from '@/extensions/calendar-block' import { Markdown } from 'tiptap-markdown' import { useEffect, useCallback, useMemo, useRef, useState } from 'react' +import { Calendar, ChevronDown, ExternalLink } from 'lucide-react' // Zero-width space used as invisible marker for blank lines const BLANK_LINE_MARKER = '\u200B' @@ -148,6 +150,8 @@ function getMarkdownWithBlankLines(editor: Editor): string { blocks.push('```chart\n' + (node.attrs?.data as string || '{}') + '\n```') } else if (node.type === 'tableBlock') { blocks.push('```table\n' + (node.attrs?.data as string || '{}') + '\n```') + } else if (node.type === 'calendarBlock') { + blocks.push('```calendar\n' + (node.attrs?.data as string || '{}') + '\n```') } else if (node.type === 'codeBlock') { const lang = (node.attrs?.language as string) || '' blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```') @@ -218,6 +222,117 @@ type WikiLinkConfig = { onCreate: (path: string) => void | Promise } +// --- Meeting Event Banner --- + +interface ParsedCalendarEvent { + summary?: string + start?: string + end?: string + location?: string + htmlLink?: string + conferenceLink?: string + source?: string +} + +function parseCalendarEvent(raw: string | undefined): ParsedCalendarEvent | null { + if (!raw) return null + // Strip surrounding quotes if present (YAML single-quoted string) + let json = raw + if ((json.startsWith("'") && json.endsWith("'")) || (json.startsWith('"') && json.endsWith('"'))) { + json = json.slice(1, -1) + } + // Unescape doubled single quotes from YAML + json = json.replace(/''/g, "'") + try { + return JSON.parse(json) as ParsedCalendarEvent + } catch { + return null + } +} + +function formatEventTime(start?: string, end?: string): string { + if (!start) return '' + const s = new Date(start) + const startStr = s.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }) + const startTime = s.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + if (!end) return `${startStr} \u00b7 ${startTime}` + const e = new Date(end) + const endTime = e.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + return `${startStr} \u00b7 ${startTime} \u2013 ${endTime}` +} + +function formatEventDate(start?: string): string { + if (!start) return '' + const s = new Date(start) + const today = new Date() + const yesterday = new Date(today) + yesterday.setDate(yesterday.getDate() - 1) + const tomorrow = new Date(today) + tomorrow.setDate(tomorrow.getDate() + 1) + + if (s.toDateString() === today.toDateString()) return 'Today' + if (s.toDateString() === yesterday.toDateString()) return 'Yesterday' + if (s.toDateString() === tomorrow.toDateString()) return 'Tomorrow' + return s.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }) +} + +function MeetingEventBanner({ frontmatter }: { frontmatter: string | null | undefined }) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + if (!open) return + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [open]) + + if (!frontmatter) return null + const fields = extractAllFrontmatterValues(frontmatter) + if (fields.type !== 'meeting') return null + + const calStr = typeof fields.calendar_event === 'string' ? fields.calendar_event : undefined + const cal = parseCalendarEvent(calStr) + if (!cal) return null + + return ( +
+ + {open && ( +
+
+ +
+
{cal.summary || 'Meeting'}
+
{formatEventTime(cal.start, cal.end)}
+
+
+ {cal.htmlLink && ( + + )} +
+ )} +
+ ) +} + +// --- Editor --- + interface MarkdownEditorProps { content: string onChange: (markdown: string) => void @@ -447,6 +562,7 @@ export function MarkdownEditor({ EmbedBlockExtension, ChartBlockExtension, TableBlockExtension, + CalendarBlockExtension, WikiLink.configure({ onCreate: wikiLinks?.onCreate ? (path) => { @@ -1201,6 +1317,7 @@ export function MarkdownEditor({ editable={editable} /> )} +
{wikiLinks ? ( diff --git a/apps/x/apps/renderer/src/extensions/calendar-block.tsx b/apps/x/apps/renderer/src/extensions/calendar-block.tsx new file mode 100644 index 00000000..dfc8e712 --- /dev/null +++ b/apps/x/apps/renderer/src/extensions/calendar-block.tsx @@ -0,0 +1,330 @@ +import { mergeAttributes, Node } from '@tiptap/react' +import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' +import { X, Calendar, Video } from 'lucide-react' +import { blocks } from '@x/shared' +import { useState, useEffect } from 'react' + +function formatTime(dateStr: string): string { + const d = new Date(dateStr) + return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) +} + +function getDateParts(dateStr: string): { day: number; month: string; weekday: string } { + const d = new Date(dateStr) + return { + day: d.getDate(), + month: d.toLocaleDateString([], { month: 'long' }), + weekday: d.toLocaleDateString([], { weekday: 'short' }), + } +} + +function getEventDate(event: blocks.CalendarEvent): string { + return event.start?.dateTime || event.start?.date || '' +} + +function isAllDay(event: blocks.CalendarEvent): boolean { + return !event.start?.dateTime && !!event.start?.date +} + +function getTimeRange(event: blocks.CalendarEvent): string { + if (isAllDay(event)) return 'All day' + const start = event.start?.dateTime + const end = event.end?.dateTime + if (!start) return '' + const startTime = formatTime(start) + if (!end) return startTime + const endTime = formatTime(end) + return `${startTime} \u2013 ${endTime}` +} + +/** + * Extract a video conference link from raw Google Calendar event JSON. + * Checks conferenceData.entryPoints (video type), hangoutLink, then falls back + * to conferenceLink if already set. + */ +function extractConferenceLink(raw: Record): string | undefined { + // Check conferenceData.entryPoints for video entry + const confData = raw.conferenceData as { entryPoints?: { entryPointType?: string; uri?: string }[] } | undefined + if (confData?.entryPoints) { + const video = confData.entryPoints.find(ep => ep.entryPointType === 'video') + if (video?.uri) return video.uri + } + // Check hangoutLink (Google Meet shortcut) + if (typeof raw.hangoutLink === 'string') return raw.hangoutLink + // Fall back to conferenceLink if present + if (typeof raw.conferenceLink === 'string') return raw.conferenceLink + return undefined +} + +interface ResolvedEvent { + event: blocks.CalendarEvent + loaded: blocks.CalendarEvent | null + conferenceLink?: string +} + +const EVENT_BAR_COLOR = '#7ec8c8' + +// Shared global to pass calendar event data to App.tsx when joining a meeting. +// Set before dispatching the custom event, read by the handler in App.tsx. +declare global { + interface Window { + __pendingCalendarEvent?: { + summary?: string + start?: { dateTime?: string; date?: string } + end?: { dateTime?: string; date?: string } + location?: string + htmlLink?: string + conferenceLink?: string + source?: string + } + } +} + +function CalendarBlockView({ node, deleteNode }: { node: { attrs: Record }; deleteNode: () => void }) { + const raw = node.attrs.data as string + let config: blocks.CalendarBlock | null = null + + try { + config = blocks.CalendarBlockSchema.parse(JSON.parse(raw)) + } catch { + // fallback below + } + + const [resolvedEvents, setResolvedEvents] = useState([]) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (!config) return + + const eventsWithSources = config.events.filter(e => e.source) + if (eventsWithSources.length === 0) { + setResolvedEvents(config.events.map(e => ({ event: e, loaded: null }))) + return + } + + setLoading(true) + const ipc = (window as unknown as { ipc: { invoke: (channel: string, args: Record) => Promise<{ data: string }> } }).ipc + + Promise.all( + config.events.map(async (event): Promise => { + if (!event.source) return { event, loaded: null } + try { + const result = await ipc.invoke('workspace:readFile', { path: event.source, encoding: 'utf8' }) + const content = typeof result === 'string' ? result : result.data + const rawEvent = JSON.parse(content) as Record + const parsed = blocks.CalendarEventSchema.parse(rawEvent) + const conferenceLink = extractConferenceLink(rawEvent) + return { event, loaded: parsed, conferenceLink } + } catch { + return { event, loaded: null } + } + }) + ).then(results => { + setResolvedEvents(results) + setLoading(false) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [raw]) + + if (!config) { + return ( + +
+ + Invalid calendar block +
+
+ ) + } + + const showJoinButton = config.showJoinButton === true + + const events = resolvedEvents.map(r => { + const e = r.loaded || r.event + return { + ...e, + htmlLink: e.htmlLink || r.event.htmlLink, + conferenceLink: r.conferenceLink || e.conferenceLink || r.event.conferenceLink, + } + }) + + // Group events by date + const dateGroups: { dateKey: string; dateStr: string; events: (blocks.CalendarEvent & { _idx: number; conferenceLink?: string })[] }[] = [] + + let globalIdx = 0 + for (const event of events) { + const dateStr = getEventDate(event) + const dateKey = dateStr ? new Date(dateStr).toDateString() : 'Unknown' + + let group = dateGroups.find(g => g.dateKey === dateKey) + if (!group) { + group = { dateKey, dateStr, events: [] } + dateGroups.push(group) + } + group.events.push({ ...event, _idx: globalIdx++ }) + } + + const handleEventClick = (event: blocks.CalendarEvent) => { + if (event.htmlLink) { + window.open(event.htmlLink, '_blank') + } + } + + const handleJoinMeeting = (event: blocks.CalendarEvent & { conferenceLink?: string }, resolvedIdx: number) => { + const meetingUrl = event.conferenceLink + if (meetingUrl) { + window.open(meetingUrl, '_blank') + } + + // Find the original source path from config + const originalEvent = config!.events[resolvedIdx] + + // Set calendar event data on window so App.tsx handler can read it + window.__pendingCalendarEvent = { + summary: event.summary, + start: event.start, + end: event.end, + location: event.location, + htmlLink: event.htmlLink, + conferenceLink: event.conferenceLink, + source: originalEvent?.source, + } + // Dispatch custom event so App.tsx can start meeting transcription + window.dispatchEvent(new Event('calendar-block:join-meeting')) + } + + return ( + +
+ + {config.title &&
{config.title}
} + {loading ? ( +
Loading events...
+ ) : events.length === 0 ? ( +
No events
+ ) : ( +
+ {dateGroups.map((group, groupIdx) => { + const parts = group.dateStr ? getDateParts(group.dateStr) : null + return ( +
+ {groupIdx > 0 &&
} +
+
+ {parts ? ( + <> + {parts.day} +
+ {parts.month} + {parts.weekday} +
+ + ) : ( + ? + )} +
+
+ {group.events.map(event => ( +
e.stopPropagation()} + onClick={(e) => { e.stopPropagation(); handleEventClick(event) }} + > +
+
+
+ {event.summary || 'Untitled event'} +
+
+ {getTimeRange(event)} +
+ {showJoinButton && event.conferenceLink && ( + + )} +
+
+ ))} +
+
+
+ ) + })} +
+ )} +
+ + ) +} + +export const CalendarBlockExtension = Node.create({ + name: 'calendarBlock', + group: 'block', + atom: true, + selectable: true, + draggable: false, + + addAttributes() { + return { + data: { + default: '{}', + }, + } + }, + + parseHTML() { + return [ + { + tag: 'pre', + priority: 60, + getAttrs(element) { + const code = element.querySelector('code') + if (!code) return false + const cls = code.className || '' + if (cls.includes('language-calendar')) { + return { data: code.textContent || '{}' } + } + return false + }, + }, + ] + }, + + renderHTML({ HTMLAttributes }: { HTMLAttributes: Record }) { + return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'calendar-block' })] + }, + + addNodeView() { + return ReactNodeViewRenderer(CalendarBlockView) + }, + + addStorage() { + return { + markdown: { + serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) { + state.write('```calendar\n' + node.attrs.data + '\n```') + state.closeBlock(node) + }, + parse: { + // handled by parseHTML + }, + }, + } + }, +}) diff --git a/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts b/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts index 3fc40cce..12743fbf 100644 --- a/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts +++ b/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts @@ -47,18 +47,46 @@ interface TranscriptEntry { text: string; } -function formatTranscript(entries: TranscriptEntry[], date: string): string { +export interface CalendarEventMeta { + summary?: string + start?: { dateTime?: string; date?: string } + end?: { dateTime?: string; date?: string } + location?: string + htmlLink?: string + conferenceLink?: string + source?: string +} + +function formatTranscript(entries: TranscriptEntry[], date: string, calendarEvent?: CalendarEventMeta): string { + const noteTitle = calendarEvent?.summary || 'Meeting note'; const lines = [ '---', 'type: meeting', 'source: rowboat', - 'title: Meeting note', + `title: ${noteTitle}`, `date: "${date}"`, + ]; + if (calendarEvent) { + // Serialize as a JSON string on one line — the frontmatter system + // only supports flat key: value pairs, not nested YAML objects. + const eventObj: Record = {} + if (calendarEvent.summary) eventObj.summary = calendarEvent.summary + if (calendarEvent.start?.dateTime) eventObj.start = calendarEvent.start.dateTime + else if (calendarEvent.start?.date) eventObj.start = calendarEvent.start.date + if (calendarEvent.end?.dateTime) eventObj.end = calendarEvent.end.dateTime + else if (calendarEvent.end?.date) eventObj.end = calendarEvent.end.date + if (calendarEvent.location) eventObj.location = calendarEvent.location + if (calendarEvent.htmlLink) eventObj.htmlLink = calendarEvent.htmlLink + if (calendarEvent.conferenceLink) eventObj.conferenceLink = calendarEvent.conferenceLink + if (calendarEvent.source) eventObj.source = calendarEvent.source + lines.push(`calendar_event: '${JSON.stringify(eventObj).replace(/'/g, "''")}'`) + } + lines.push( '---', '', - '# Meeting note', + `# ${noteTitle}`, '', - ]; + ); for (let i = 0; i < entries.length; i++) { if (i > 0 && entries[i].speaker !== entries[i - 1].speaker) { lines.push(''); @@ -87,6 +115,7 @@ export function useMeetingTranscription(onAutoStop?: () => void) { const onAutoStopRef = useRef(onAutoStop); onAutoStopRef.current = onAutoStop; const dateRef = useRef(''); + const calendarEventRef = useRef(undefined); const writeTranscriptToFile = useCallback(async () => { if (!notePathRef.current) return; @@ -100,7 +129,7 @@ export function useMeetingTranscription(onAutoStop?: () => void) { } } if (entries.length === 0) return; - const content = formatTranscript(entries, dateRef.current); + const content = formatTranscript(entries, dateRef.current, calendarEventRef.current); try { await window.ipc.invoke('workspace:writeFile', { path: notePathRef.current, @@ -151,7 +180,7 @@ export function useMeetingTranscription(onAutoStop?: () => void) { } }, []); - const start = useCallback(async (): Promise => { + const start = useCallback(async (calendarEvent?: CalendarEventMeta): Promise => { if (state !== 'idle') return null; setState('connecting'); @@ -345,10 +374,13 @@ export function useMeetingTranscription(onAutoStop?: () => void) { dateRef.current = dateStr; const dateFolder = dateStr.split('T')[0]; // YYYY-MM-DD const timestamp = dateStr.replace(/:/g, '-').replace(/\.\d+Z$/, ''); - const notePath = `knowledge/Meetings/rowboat/${dateFolder}/meeting-${timestamp}.md`; + const filename = calendarEvent?.summary + ? calendarEvent.summary.replace(/[\\/*?:"<>|]/g, '').replace(/\s+/g, '_').substring(0, 100).trim() + : `meeting-${timestamp}`; + const notePath = `knowledge/Meetings/rowboat/${dateFolder}/${filename}.md`; notePathRef.current = notePath; - - const initialContent = formatTranscript([], dateStr); + calendarEventRef.current = calendarEvent; + const initialContent = formatTranscript([], dateStr, calendarEvent); await window.ipc.invoke('workspace:writeFile', { path: notePath, data: initialContent, diff --git a/apps/x/apps/renderer/src/styles/editor.css b/apps/x/apps/renderer/src/styles/editor.css index 12767630..b30ca9d1 100644 --- a/apps/x/apps/renderer/src/styles/editor.css +++ b/apps/x/apps/renderer/src/styles/editor.css @@ -616,14 +616,16 @@ .tiptap-editor .ProseMirror .image-block-wrapper, .tiptap-editor .ProseMirror .embed-block-wrapper, .tiptap-editor .ProseMirror .chart-block-wrapper, -.tiptap-editor .ProseMirror .table-block-wrapper { +.tiptap-editor .ProseMirror .table-block-wrapper, +.tiptap-editor .ProseMirror .calendar-block-wrapper { margin: 8px 0; } .tiptap-editor .ProseMirror .image-block-card, .tiptap-editor .ProseMirror .embed-block-card, .tiptap-editor .ProseMirror .chart-block-card, -.tiptap-editor .ProseMirror .table-block-card { +.tiptap-editor .ProseMirror .table-block-card, +.tiptap-editor .ProseMirror .calendar-block-card { position: relative; padding: 12px 14px; border: 1px solid var(--border); @@ -636,14 +638,16 @@ .tiptap-editor .ProseMirror .image-block-card:hover, .tiptap-editor .ProseMirror .embed-block-card:hover, .tiptap-editor .ProseMirror .chart-block-card:hover, -.tiptap-editor .ProseMirror .table-block-card:hover { +.tiptap-editor .ProseMirror .table-block-card:hover, +.tiptap-editor .ProseMirror .calendar-block-card:hover { background-color: color-mix(in srgb, var(--muted) 70%, transparent); } .tiptap-editor .ProseMirror .image-block-wrapper.ProseMirror-selectednode .image-block-card, .tiptap-editor .ProseMirror .embed-block-wrapper.ProseMirror-selectednode .embed-block-card, .tiptap-editor .ProseMirror .chart-block-wrapper.ProseMirror-selectednode .chart-block-card, -.tiptap-editor .ProseMirror .table-block-wrapper.ProseMirror-selectednode .table-block-card { +.tiptap-editor .ProseMirror .table-block-wrapper.ProseMirror-selectednode .table-block-card, +.tiptap-editor .ProseMirror .calendar-block-wrapper.ProseMirror-selectednode .calendar-block-card { outline: 2px solid var(--primary); outline-offset: 1px; } @@ -651,7 +655,8 @@ .tiptap-editor .ProseMirror .image-block-delete, .tiptap-editor .ProseMirror .embed-block-delete, .tiptap-editor .ProseMirror .chart-block-delete, -.tiptap-editor .ProseMirror .table-block-delete { +.tiptap-editor .ProseMirror .table-block-delete, +.tiptap-editor .ProseMirror .calendar-block-delete { position: absolute; top: 6px; right: 6px; @@ -673,14 +678,16 @@ .tiptap-editor .ProseMirror .image-block-card:hover .image-block-delete, .tiptap-editor .ProseMirror .embed-block-card:hover .embed-block-delete, .tiptap-editor .ProseMirror .chart-block-card:hover .chart-block-delete, -.tiptap-editor .ProseMirror .table-block-card:hover .table-block-delete { +.tiptap-editor .ProseMirror .table-block-card:hover .table-block-delete, +.tiptap-editor .ProseMirror .calendar-block-card:hover .calendar-block-delete { opacity: 1; } .tiptap-editor .ProseMirror .image-block-delete:hover, .tiptap-editor .ProseMirror .embed-block-delete:hover, .tiptap-editor .ProseMirror .chart-block-delete:hover, -.tiptap-editor .ProseMirror .table-block-delete:hover { +.tiptap-editor .ProseMirror .table-block-delete:hover, +.tiptap-editor .ProseMirror .calendar-block-delete:hover { background-color: color-mix(in srgb, var(--foreground) 8%, transparent); color: var(--foreground); } @@ -836,6 +843,264 @@ font-size: 13px; } +/* Calendar block */ +.tiptap-editor .ProseMirror .calendar-block-title { + font-size: 14px; + font-weight: 600; + margin-bottom: 8px; + color: var(--foreground); +} + +.tiptap-editor .ProseMirror .calendar-block-loading, +.tiptap-editor .ProseMirror .calendar-block-empty { + display: flex; + align-items: center; + justify-content: center; + height: 60px; + font-size: 13px; + color: color-mix(in srgb, var(--foreground) 45%, transparent); +} + +.tiptap-editor .ProseMirror .calendar-block-error { + display: flex; + align-items: center; + gap: 6px; + color: color-mix(in srgb, var(--foreground) 55%, transparent); + font-size: 13px; +} + +.tiptap-editor .ProseMirror .calendar-block-list { + display: flex; + flex-direction: column; +} + +.tiptap-editor .ProseMirror .calendar-block-date-group { + display: flex; + flex-direction: column; +} + +.tiptap-editor .ProseMirror .calendar-block-separator { + border: none; + border-top: 1px dashed color-mix(in srgb, var(--foreground) 20%, transparent); + margin: 4px 0; +} + +.tiptap-editor .ProseMirror .calendar-block-date-row { + display: flex; + align-items: flex-start; + gap: 0; + padding: 12px 0; +} + +.tiptap-editor .ProseMirror .calendar-block-date-left { + display: flex; + align-items: baseline; + gap: 6px; + width: 140px; + flex-shrink: 0; + padding-top: 4px; +} + +.tiptap-editor .ProseMirror .calendar-block-day { + font-size: 28px; + font-weight: 300; + line-height: 1; + color: color-mix(in srgb, var(--foreground) 70%, transparent); +} + +.tiptap-editor .ProseMirror .calendar-block-month-weekday { + display: flex; + flex-direction: column; + gap: 0; +} + +.tiptap-editor .ProseMirror .calendar-block-month { + font-size: 12px; + color: color-mix(in srgb, var(--foreground) 50%, transparent); + line-height: 1.3; +} + +.tiptap-editor .ProseMirror .calendar-block-weekday { + font-size: 12px; + color: color-mix(in srgb, var(--foreground) 40%, transparent); + line-height: 1.3; +} + +.tiptap-editor .ProseMirror .calendar-block-events { + display: flex; + flex-direction: column; + gap: 12px; + flex: 1; + min-width: 0; +} + +.tiptap-editor .ProseMirror .calendar-block-event { + display: flex; + align-items: stretch; + gap: 10px; + padding: 4px 8px; + border-radius: 6px; + transition: background-color 0.12s ease; +} + +.tiptap-editor .ProseMirror .calendar-block-event-clickable { + cursor: pointer; +} + +.tiptap-editor .ProseMirror .calendar-block-event-clickable:hover { + background-color: color-mix(in srgb, var(--foreground) 5%, transparent); +} + +.tiptap-editor .ProseMirror .calendar-block-event-bar { + width: 3px; + border-radius: 2px; + flex-shrink: 0; + min-height: 32px; +} + +.tiptap-editor .ProseMirror .calendar-block-event-content { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.tiptap-editor .ProseMirror .calendar-block-event-title { + font-size: 14px; + font-weight: 500; + color: var(--foreground); +} + +.tiptap-editor .ProseMirror .calendar-block-event-time { + font-size: 12px; + color: color-mix(in srgb, var(--foreground) 45%, transparent); +} + +.tiptap-editor .ProseMirror .calendar-block-join-btn { + display: inline-flex; + align-items: center; + gap: 5px; + margin-top: 4px; + padding: 4px 10px; + font-size: 12px; + font-weight: 500; + color: #7ec8c8; + background: color-mix(in srgb, #7ec8c8 12%, transparent); + border: 1px solid color-mix(in srgb, #7ec8c8 25%, transparent); + border-radius: 5px; + cursor: pointer; + transition: background-color 0.12s ease, border-color 0.12s ease; + width: fit-content; +} + +.tiptap-editor .ProseMirror .calendar-block-join-btn:hover { + background: color-mix(in srgb, #7ec8c8 22%, transparent); + border-color: color-mix(in srgb, #7ec8c8 40%, transparent); +} + +/* Meeting event banner */ +.meeting-event-banner { + position: relative; + display: flex; + align-items: center; + gap: 6px; + padding: 4px 20px 0; +} + +.meeting-event-pill { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + font-size: 12px; + font-weight: 500; + color: color-mix(in srgb, var(--foreground) 65%, transparent); + background: color-mix(in srgb, var(--foreground) 6%, transparent); + border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); + border-radius: 14px; + cursor: pointer; + transition: background-color 0.12s ease; +} + +.meeting-event-pill:hover { + background: color-mix(in srgb, var(--foreground) 10%, transparent); +} + +.meeting-event-chevron { + transition: transform 0.15s ease; +} + +.meeting-event-chevron-open { + transform: rotate(180deg); +} + +.meeting-event-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 20px; + z-index: 50; + min-width: 260px; + padding: 10px 12px; + background: var(--background); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + display: flex; + flex-direction: column; + gap: 8px; +} + +.meeting-event-dropdown-header { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.meeting-event-dropdown-dot { + width: 8px; + height: 8px; + border-radius: 2px; + background: #7ec8c8; + flex-shrink: 0; + margin-top: 5px; +} + +.meeting-event-dropdown-info { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.meeting-event-dropdown-title { + font-size: 14px; + font-weight: 500; + color: var(--foreground); +} + +.meeting-event-dropdown-time { + font-size: 12px; + color: color-mix(in srgb, var(--foreground) 50%, transparent); +} + +.meeting-event-dropdown-link { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + font-size: 13px; + color: var(--foreground); + background: none; + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + transition: background-color 0.12s ease; +} + +.meeting-event-dropdown-link:hover { + background: color-mix(in srgb, var(--foreground) 5%, transparent); +} + /* Dark mode overrides */ .dark .tiptap-editor .ProseMirror { color: rgba(255, 255, 255, 0.9); diff --git a/apps/x/packages/core/src/knowledge/summarize_meeting.ts b/apps/x/packages/core/src/knowledge/summarize_meeting.ts index 6738a957..d3d09248 100644 --- a/apps/x/packages/core/src/knowledge/summarize_meeting.ts +++ b/apps/x/packages/core/src/knowledge/summarize_meeting.ts @@ -12,13 +12,14 @@ const SYSTEM_PROMPT = `You are a meeting notes assistant. Given a raw meeting tr ## Calendar matching You will be given the transcript (with a timestamp of when recording started) and recent calendar events with their titles, times, and attendees. If a calendar event clearly matches this meeting (overlapping time + content aligns), then: -- Use the calendar event title as the meeting title (output it as the first line: "## ") +- Do NOT output a title or heading — the title is already set by the caller. - Replace generic speaker labels ("Speaker 0", "Speaker 1", "System audio") with actual attendee names, but ONLY if you have HIGH CONFIDENCE about which speaker is which based on the discussion content. If unsure, use "They" instead of "Speaker 0" etc. - "You" in the transcript is the local user — if the calendar event has an organizer or you can identify who "You" is from context, use their name. -If no calendar event matches with high confidence, or if no calendar events are provided, skip the title line and use "They" for all non-"You" speakers. +If no calendar event matches with high confidence, or if no calendar events are provided, use "They" for all non-"You" speakers. ## Format rules +- Do NOT output a title or top-level heading (# or ##). Start directly with section content. - Use ### for section headers that group related discussion topics - Section headers should be in sentence case (e.g. "### Onboarding flow status"), NOT Title Case - Use bullet points with sub-bullets for details @@ -83,13 +84,70 @@ function loadRecentCalendarEvents(meetingTime: string): string { } } -export async function summarizeMeeting(transcript: string, meetingStartTime?: string): Promise { +/** + * Load a specific calendar event from the calendar_sync directory using + * the calendar_event JSON stored in the meeting note frontmatter. + * If a `source` field is present, loads the full event file for richer + * details (attendees, organizer, etc.). + */ +function loadCalendarEventContext(calendarEventJson: string): string { + try { + const meta = JSON.parse(calendarEventJson) as { + summary?: string; + start?: string; + end?: string; + location?: string; + htmlLink?: string; + conferenceLink?: string; + source?: string; + }; + + // Try to load the full event from source file for attendee info + let attendees = ''; + let organizer = ''; + if (meta.source) { + try { + const fullPath = path.join(WorkDir, meta.source); + if (fs.existsSync(fullPath)) { + const event = JSON.parse(fs.readFileSync(fullPath, 'utf-8')); + attendees = (event.attendees || []) + .map((a: { displayName?: string; email?: string }) => a.displayName || a.email) + .filter(Boolean) + .join(', '); + organizer = event.organizer?.displayName || event.organizer?.email || ''; + } + } catch { + // Fall through — use metadata only + } + } + + const eventStr = + `- Title: ${meta.summary || 'Untitled'}\n` + + ` Start: ${meta.start || ''}\n` + + ` End: ${meta.end || ''}\n` + + ` Organizer: ${organizer || 'unknown'}\n` + + ` Attendees: ${attendees || 'none listed'}`; + + return `\n\n## Calendar event for this meeting\n\n${eventStr}`; + } catch { + return ''; + } +} + +export async function summarizeMeeting(transcript: string, meetingStartTime?: string, calendarEventJson?: string): Promise { const repo = container.resolve('modelConfigRepo'); const config = await repo.getConfig(); const provider = createProvider(config.provider); const model = provider.languageModel(config.model); - const calendarContext = meetingStartTime ? loadRecentCalendarEvents(meetingStartTime) : ''; + // If a specific calendar event was linked, use it directly. + // Otherwise fall back to scanning events within ±3 hours. + let calendarContext: string; + if (calendarEventJson) { + calendarContext = loadCalendarEventContext(calendarEventJson); + } else { + calendarContext = meetingStartTime ? loadRecentCalendarEvents(meetingStartTime) : ''; + } const prompt = `Meeting recording started at: ${meetingStartTime || 'unknown'}\n\n${transcript}${calendarContext}`; diff --git a/apps/x/packages/shared/src/blocks.ts b/apps/x/packages/shared/src/blocks.ts index bbb3f77f..89e35909 100644 --- a/apps/x/packages/shared/src/blocks.ts +++ b/apps/x/packages/shared/src/blocks.ts @@ -34,3 +34,29 @@ export const TableBlockSchema = z.object({ }); export type TableBlock = z.infer; + +export const CalendarEventSchema = z.object({ + summary: z.string().optional(), + start: z.object({ + dateTime: z.string().optional(), + date: z.string().optional(), + }).optional(), + end: z.object({ + dateTime: z.string().optional(), + date: z.string().optional(), + }).optional(), + location: z.string().optional(), + htmlLink: z.string().optional(), + conferenceLink: z.string().optional(), + source: z.string().optional(), +}); + +export type CalendarEvent = z.infer; + +export const CalendarBlockSchema = z.object({ + title: z.string().optional(), + events: z.array(CalendarEventSchema), + showJoinButton: z.boolean().optional(), +}); + +export type CalendarBlock = z.infer; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 7374296b..82dc5275 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -502,6 +502,7 @@ const ipcSchemas = { req: z.object({ transcript: z.string(), meetingStartTime: z.string().optional(), + calendarEventJson: z.string().optional(), }), res: z.object({ notes: z.string(),