cal block initial commit

This commit is contained in:
Arjun 2026-03-20 15:03:34 +05:30 committed by arkml
parent 484231b224
commit c03882f43f
9 changed files with 932 additions and 41 deletions

View file

@ -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) => {

View file

@ -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<typeof workspace.DirEntry>
type RunEventType = z.infer<typeof RunEvent>
@ -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<string, TreeNode[]>()
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<string, TreeNode>()
@ -3362,13 +3386,16 @@ function App() {
}, [loadDirectory, navigateToFile, fileTabs])
const meetingNotePathRef = useRef<string | null>(null)
const pendingCalendarEventRef = useRef<CalendarEventMeta | undefined>(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

View file

@ -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<void>
}
// --- 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<HTMLDivElement>(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 (
<div className="meeting-event-banner" ref={ref}>
<button
className="meeting-event-pill"
onClick={() => setOpen(!open)}
>
<Calendar size={13} />
{formatEventDate(cal.start)}
<ChevronDown size={12} className={`meeting-event-chevron ${open ? 'meeting-event-chevron-open' : ''}`} />
</button>
{open && (
<div className="meeting-event-dropdown">
<div className="meeting-event-dropdown-header">
<span className="meeting-event-dropdown-dot" />
<div className="meeting-event-dropdown-info">
<div className="meeting-event-dropdown-title">{cal.summary || 'Meeting'}</div>
<div className="meeting-event-dropdown-time">{formatEventTime(cal.start, cal.end)}</div>
</div>
</div>
{cal.htmlLink && (
<button
className="meeting-event-dropdown-link"
onClick={() => window.open(cal.htmlLink, '_blank')}
>
<ExternalLink size={14} />
Open in Google Calendar
</button>
)}
</div>
)}
</div>
)
}
// --- 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}
/>
)}
<MeetingEventBanner frontmatter={frontmatter} />
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
<EditorContent editor={editor} />
{wikiLinks ? (

View file

@ -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, unknown>): 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<string, unknown> }; 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<ResolvedEvent[]>([])
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<string, string>) => Promise<{ data: string }> } }).ipc
Promise.all(
config.events.map(async (event): Promise<ResolvedEvent> => {
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<string, unknown>
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 (
<NodeViewWrapper className="calendar-block-wrapper" data-type="calendar-block">
<div className="calendar-block-card calendar-block-error">
<Calendar size={16} />
<span>Invalid calendar block</span>
</div>
</NodeViewWrapper>
)
}
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 (
<NodeViewWrapper className="calendar-block-wrapper" data-type="calendar-block">
<div className="calendar-block-card">
<button
className="calendar-block-delete"
onClick={deleteNode}
aria-label="Delete calendar block"
>
<X size={14} />
</button>
{config.title && <div className="calendar-block-title">{config.title}</div>}
{loading ? (
<div className="calendar-block-loading">Loading events...</div>
) : events.length === 0 ? (
<div className="calendar-block-empty">No events</div>
) : (
<div className="calendar-block-list">
{dateGroups.map((group, groupIdx) => {
const parts = group.dateStr ? getDateParts(group.dateStr) : null
return (
<div key={group.dateKey} className="calendar-block-date-group">
{groupIdx > 0 && <div className="calendar-block-separator" />}
<div className="calendar-block-date-row">
<div className="calendar-block-date-left">
{parts ? (
<>
<span className="calendar-block-day">{parts.day}</span>
<div className="calendar-block-month-weekday">
<span className="calendar-block-month">{parts.month}</span>
<span className="calendar-block-weekday">{parts.weekday}</span>
</div>
</>
) : (
<span className="calendar-block-day">?</span>
)}
</div>
<div className="calendar-block-events">
{group.events.map(event => (
<div
key={event._idx}
className={`calendar-block-event ${event.htmlLink ? 'calendar-block-event-clickable' : ''}`}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); handleEventClick(event) }}
>
<div
className="calendar-block-event-bar"
style={{ backgroundColor: EVENT_BAR_COLOR }}
/>
<div className="calendar-block-event-content">
<div className="calendar-block-event-title">
{event.summary || 'Untitled event'}
</div>
<div className="calendar-block-event-time">
{getTimeRange(event)}
</div>
{showJoinButton && event.conferenceLink && (
<button
className="calendar-block-join-btn"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); handleJoinMeeting(event, event._idx) }}
>
<Video size={13} />
Join meeting & take notes
</button>
)}
</div>
</div>
))}
</div>
</div>
</div>
)
})}
</div>
)}
</div>
</NodeViewWrapper>
)
}
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<string, unknown> }) {
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
},
},
}
},
})

View file

@ -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<string, string> = {}
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<string>('');
const calendarEventRef = useRef<CalendarEventMeta | undefined>(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<string | null> => {
const start = useCallback(async (calendarEvent?: CalendarEventMeta): Promise<string | null> => {
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,

View file

@ -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);

View file

@ -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: "## <event title>")
- 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<string> {
/**
* 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<string> {
const repo = container.resolve<IModelConfigRepo>('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}`;

View file

@ -34,3 +34,29 @@ export const TableBlockSchema = z.object({
});
export type TableBlock = z.infer<typeof TableBlockSchema>;
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<typeof CalendarEventSchema>;
export const CalendarBlockSchema = z.object({
title: z.string().optional(),
events: z.array(CalendarEventSchema),
showJoinButton: z.boolean().optional(),
});
export type CalendarBlock = z.infer<typeof CalendarBlockSchema>;

View file

@ -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(),