diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index c79a8c43..178cb7e1 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -11,6 +11,9 @@ module.exports = { icon: './icons/icon', // .icns extension added automatically appBundleId: 'com.rowboat.app', appCategoryType: 'public.app-category.productivity', + extendInfo: { + NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)', + }, osxSign: { batchCodesignCalls: true, optionsForFile: () => ({ diff --git a/apps/x/apps/main/src/auth-server.ts b/apps/x/apps/main/src/auth-server.ts index b0b890c0..78e519d0 100644 --- a/apps/x/apps/main/src/auth-server.ts +++ b/apps/x/apps/main/src/auth-server.ts @@ -25,7 +25,7 @@ export interface AuthServerResult { */ export function createAuthServer( port: number = DEFAULT_PORT, - onCallback: (code: string, state: string) => void | Promise + onCallback: (params: Record) => void | Promise ): Promise { return new Promise((resolve, reject) => { const server = createServer((req, res) => { @@ -67,7 +67,7 @@ export function createAuthServer( // Handle callback - either traditional OAuth with code/state or Composio-style notification // Composio callbacks may not have code/state, just a notification that the flow completed - onCallback(code || '', state || ''); + onCallback(Object.fromEntries(url.searchParams.entries())); res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(` diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts index c7449bcb..dad0acbe 100644 --- a/apps/x/apps/main/src/composio-handler.ts +++ b/apps/x/apps/main/src/composio-handler.ts @@ -150,7 +150,7 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ // Set up callback server let cleanupTimeout: NodeJS.Timeout; let callbackHandled = false; - const { server } = await createAuthServer(8081, async (_code, _state) => { + const { server } = await createAuthServer(8081, async () => { // Guard against duplicate callbacks (browser may send multiple requests) if (callbackHandled) return; callbackHandled = true; diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 1327500d..aa5222f8 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1,4 +1,4 @@ -import { ipcMain, BrowserWindow, shell, dialog } from 'electron'; +import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer } from 'electron'; import { ipc } from '@x/shared'; import path from 'node:path'; import os from 'node:os'; @@ -735,6 +735,24 @@ export function setupIpcHandlers() { return { success: false, error: 'Unknown format' }; }, + 'meeting:checkScreenPermission': async () => { + if (process.platform !== 'darwin') return { granted: true }; + const status = systemPreferences.getMediaAccessStatus('screen'); + console.log('[meeting] Screen recording permission status:', status); + if (status === 'granted') return { granted: true }; + // Not granted — call desktopCapturer.getSources() to register the app + // in the macOS Screen Recording list. On first call this shows the + // native permission prompt (signed apps are remembered across restarts). + try { await desktopCapturer.getSources({ types: ['screen'] }); } catch { /* ignore */ } + // Re-check after the native prompt was dismissed + const statusAfter = systemPreferences.getMediaAccessStatus('screen'); + console.log('[meeting] Screen recording permission status after prompt:', statusAfter); + return { granted: statusAfter === 'granted' }; + }, + 'meeting:openScreenRecordingSettings': async () => { + await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'); + return { success: true }; + }, 'meeting:summarize': async (_event, args) => { const notes = await summarizeMeeting(args.transcript, args.meetingStartTime, args.calendarEventJson); return { notes }; diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 3b8b70c8..d828f38d 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -25,6 +25,7 @@ import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js"; import { initConfigs } from "@x/core/dist/config/initConfigs.js"; import started from "electron-squirrel-startup"; import { execSync } from "node:child_process"; +import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -234,6 +235,9 @@ app.whenReady().then(async () => { // start agent notes learning service initAgentNotes(); + // start chrome extension sync server + initChromeSync(); + app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 99865d8e..dde2246d 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -187,12 +187,12 @@ export async function connectProvider(provider: string, clientId?: string): Prom // Create callback server let callbackHandled = false; - const { server } = await createAuthServer(8080, async (code, receivedState) => { + const { server } = await createAuthServer(8080, async (params: Record) => { // Guard against duplicate callbacks (browser may send multiple requests) if (callbackHandled) return; callbackHandled = true; // Validate state - if (receivedState !== state) { + if (params.state !== state) { throw new Error('Invalid state parameter - possible CSRF attack'); } @@ -203,7 +203,7 @@ export async function connectProvider(provider: string, clientId?: string): Prom try { // Build callback URL for token exchange - const callbackUrl = new URL(`${REDIRECT_URI}?code=${code}&state=${receivedState}`); + const callbackUrl = new URL(`${REDIRECT_URI}?${new URLSearchParams(params).toString()}`); // Exchange code for tokens console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 591ef21e..b2bc9d7f 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -33,6 +33,7 @@ import { } from '@/components/ai-elements/prompt-input'; import { Shimmer } from '@/components/ai-elements/shimmer'; +import { useSmoothedText } from './hooks/useSmoothedText'; import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool'; import { WebSearchResult } from '@/components/ai-elements/web-search-result'; import { AppActionCard } from '@/components/ai-elements/app-action-card'; @@ -93,6 +94,11 @@ interface TreeNode extends DirEntry { const streamdownComponents = { pre: MarkdownPreOverride } +function SmoothStreamingMessage({ text, components }: { text: string; components: typeof streamdownComponents }) { + const smoothText = useSmoothedText(text) + return {smoothText} +} + const DEFAULT_SIDEBAR_WIDTH = 256 const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g const graphPalette = [ @@ -478,7 +484,7 @@ function FixedSidebarToggle({ )} style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }} > - {meetingSummarizing ? ( + {meetingSummarizing || meetingState === 'connecting' ? ( ) : meetingState === 'recording' ? ( @@ -488,7 +494,7 @@ function FixedSidebarToggle({ - {meetingSummarizing ? 'Generating meeting notes...' : meetingState === 'recording' ? 'Stop meeting notes' : 'Take new meeting notes'} + {meetingSummarizing ? 'Generating meeting notes...' : meetingState === 'connecting' ? 'Starting transcription...' : meetingState === 'recording' ? 'Stop meeting notes' : 'Take new meeting notes'} )} @@ -697,13 +703,18 @@ function App() { window.ipc.invoke('oauth:getState', null), ]).then(([config, oauthState]) => { const rowboatConnected = oauthState.config?.rowboat?.connected ?? false - setVoiceAvailable(!!config.deepgram || rowboatConnected) + const hasVoice = !!config.deepgram || rowboatConnected + setVoiceAvailable(hasVoice) setTtsAvailable(!!config.elevenlabs || rowboatConnected) + // Pre-cache auth details so mic click skips IPC round-trips + if (hasVoice) { + voice.warmup() + } }).catch(() => { setVoiceAvailable(false) setTtsAvailable(false) }) - }, []) + }, [voice.warmup]) useEffect(() => { refreshVoiceAvailability() @@ -754,6 +765,22 @@ function App() { isRecordingRef.current = false }, [voice]) + // Enter to submit voice input, Escape to cancel + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isRecordingRef.current) return + if (e.key === 'Enter') { + e.preventDefault() + handleSubmitRecording() + } else if (e.key === 'Escape') { + e.preventDefault() + handleCancelRecording() + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [handleSubmitRecording, handleCancelRecording]) + // Helper to cancel recording from any navigation handler const cancelRecordingIfActive = useCallback(() => { if (isRecordingRef.current) { @@ -3390,9 +3417,9 @@ function App() { const [meetingSummarizing, setMeetingSummarizing] = useState(false) const [showMeetingPermissions, setShowMeetingPermissions] = useState(false) - const startMeetingAfterPermissions = useCallback(async () => { - setShowMeetingPermissions(false) - localStorage.setItem('meeting-permissions-acknowledged', '1') + const [checkingPermission, setCheckingPermission] = useState(false) + + const startMeetingNow = useCallback(async () => { const calEvent = pendingCalendarEventRef.current pendingCalendarEventRef.current = undefined const notePath = await meetingTranscription.start(calEvent) @@ -3402,6 +3429,23 @@ function App() { } }, [meetingTranscription, handleVoiceNoteCreated]) + const handleCheckPermissionAndRetry = useCallback(async () => { + setCheckingPermission(true) + try { + const { granted } = await window.ipc.invoke('meeting:checkScreenPermission', null) + if (granted) { + setShowMeetingPermissions(false) + await startMeetingNow() + } + } finally { + setCheckingPermission(false) + } + }, [startMeetingNow]) + + const handleOpenScreenRecordingSettings = useCallback(async () => { + await window.ipc.invoke('meeting:openScreenRecordingSettings', null) + }, []) + const handleToggleMeeting = useCallback(async () => { if (meetingTranscription.state === 'recording') { await meetingTranscription.stop() @@ -3423,16 +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 noteTitle = fmTitleMatch?.[1]?.trim() || 'Meeting Notes' const cleanedNotes = notes.replace(/^#{1,2}\s+.+\n+/, '') - const newBody = `# ${noteTitle}\n\n` + cleanedNotes + '\n\n---\n\n## Raw transcript\n\n' + bodyWithoutTitle + // 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, @@ -3450,20 +3493,18 @@ function App() { meetingNotePathRef.current = null } } else if (meetingTranscription.state === 'idle') { - // Show permissions modal on first use (macOS only — Windows works out of the box) - if (isMac && !localStorage.getItem('meeting-permissions-acknowledged')) { - setShowMeetingPermissions(true) - return - } - const calEvent = pendingCalendarEventRef.current - pendingCalendarEventRef.current = undefined - const notePath = await meetingTranscription.start(calEvent) - if (notePath) { - meetingNotePathRef.current = notePath - await handleVoiceNoteCreated(notePath) + // On macOS, check screen recording permission before starting + if (isMac) { + const result = await window.ipc.invoke('meeting:checkScreenPermission', null) + console.log('[meeting] Permission check result:', result) + if (!result.granted) { + setShowMeetingPermissions(true) + return + } } + await startMeetingNow() } - }, [meetingTranscription, handleVoiceNoteCreated]) + }, [meetingTranscription, handleVoiceNoteCreated, startMeetingNow]) handleToggleMeetingRef.current = handleToggleMeeting // Listen for calendar block "join meeting & take notes" events @@ -4237,7 +4278,7 @@ function App() { {tabState.currentAssistantMessage && ( - {tabState.currentAssistantMessage.replace(/<\/?voice>/g, '')} + /g, '')} components={streamdownComponents} /> )} @@ -4394,23 +4435,25 @@ function App() { - Meeting transcription setup + Screen recording permission required - Rowboat needs Screen Recording permission to capture meeting audio from other apps (Zoom, Meet, etc.). + Rowboat needs Screen Recording permission to capture meeting audio from other apps (Zoom, Meet, etc.). This feature won't work without it.

To enable this:

    -
  1. Open System SettingsPrivacy & Security
  2. -
  3. Click Screen Recording
  4. +
  5. Open System SettingsPrivacy & SecurityScreen Recording
  6. Toggle on Rowboat
  7. You may need to restart the app after granting permission
- + +
diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 0c041351..03ab3f94 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -266,7 +266,7 @@ function ChatInputInner({ return () => window.removeEventListener('models-config-changed', handler) }, [loadModelConfig]) - // Check search tool availability (brave or exa, or signed-in via gateway) + // Check search tool availability (exa or signed-in via gateway) useEffect(() => { const checkSearch = async () => { if (isRowboatConnected) { @@ -275,17 +275,10 @@ function ChatInputInner({ } let available = false try { - const raw = await window.ipc.invoke('workspace:readFile', { path: 'config/brave-search.json' }) + const raw = await window.ipc.invoke('workspace:readFile', { path: 'config/exa-search.json' }) const config = JSON.parse(raw.data) if (config.apiKey) available = true } catch { /* not configured */ } - if (!available) { - try { - const raw = await window.ipc.invoke('workspace:readFile', { path: 'config/exa-search.json' }) - const config = JSON.parse(raw.data) - if (config.apiKey) available = true - } catch { /* not configured */ } - } setSearchAvailable(available) } checkSearch() diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 2592dec3..d7920b8b 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -15,6 +15,7 @@ import { ChartBlockExtension } from '@/extensions/chart-block' import { TableBlockExtension } from '@/extensions/table-block' import { CalendarBlockExtension } from '@/extensions/calendar-block' import { EmailBlockExtension } from '@/extensions/email-block' +import { TranscriptBlockExtension } from '@/extensions/transcript-block' import { Markdown } from 'tiptap-markdown' import { useEffect, useCallback, useMemo, useRef, useState } from 'react' import { Calendar, ChevronDown, ExternalLink } from 'lucide-react' @@ -108,39 +109,44 @@ function getMarkdownWithBlankLines(editor: Editor): string { const level = (node.attrs?.level as number) || 1 const text = nodeToText(node) blocks.push('#'.repeat(level) + ' ' + text) - } else if (node.type === 'bulletList' || node.type === 'orderedList') { - // Handle lists - all items are part of one block - const listLines: string[] = [] - const listItems = (node.content || []) as Array<{ content?: Array; attrs?: Record }> - listItems.forEach((item, index) => { - const prefix = node.type === 'orderedList' ? `${index + 1}. ` : '- ' - const itemContent = (item.content || []) as Array<{ type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }> - itemContent.forEach((para: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }, paraIndex: number) => { - const text = nodeToText(para) - if (paraIndex === 0) { - listLines.push(prefix + text) + } else if (node.type === 'bulletList' || node.type === 'orderedList' || node.type === 'taskList') { + // Recursively serialize lists to handle nested bullets + const serializeList = ( + listNode: { type?: string; content?: Array>; attrs?: Record }, + indent: number + ): string[] => { + const lines: string[] = [] + const items = (listNode.content || []) as Array<{ content?: Array>; attrs?: Record }> + items.forEach((item, index) => { + const indentStr = ' '.repeat(indent) + let prefix: string + if (listNode.type === 'taskList') { + const checked = item.attrs?.checked ? 'x' : ' ' + prefix = `- [${checked}] ` + } else if (listNode.type === 'orderedList') { + prefix = `${index + 1}. ` } else { - listLines.push(' ' + text) + prefix = '- ' } + const itemContent = (item.content || []) as Array<{ type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }> + let firstPara = true + itemContent.forEach(child => { + if (child.type === 'bulletList' || child.type === 'orderedList' || child.type === 'taskList') { + lines.push(...serializeList(child, indent + 1)) + } else { + const text = nodeToText(child) + if (firstPara) { + lines.push(indentStr + prefix + text) + firstPara = false + } else { + lines.push(indentStr + ' ' + text) + } + } + }) }) - }) - blocks.push(listLines.join('\n')) - } else if (node.type === 'taskList') { - const listLines: string[] = [] - const listItems = (node.content || []) as Array<{ content?: Array; attrs?: Record }> - listItems.forEach(item => { - const checked = item.attrs?.checked ? 'x' : ' ' - const itemContent = (item.content || []) as Array<{ type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }> - itemContent.forEach((para: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }, paraIndex: number) => { - const text = nodeToText(para) - if (paraIndex === 0) { - listLines.push(`- [${checked}] ${text}`) - } else { - listLines.push(' ' + text) - } - }) - }) - blocks.push(listLines.join('\n')) + return lines + } + blocks.push(serializeList(node, 0).join('\n')) } else if (node.type === 'taskBlock') { blocks.push('```task\n' + (node.attrs?.data as string || '{}') + '\n```') } else if (node.type === 'imageBlock') { @@ -155,6 +161,8 @@ function getMarkdownWithBlankLines(editor: Editor): string { blocks.push('```calendar\n' + (node.attrs?.data as string || '{}') + '\n```') } else if (node.type === 'emailBlock') { blocks.push('```email\n' + (node.attrs?.data as string || '{}') + '\n```') + } else if (node.type === 'transcriptBlock') { + blocks.push('```transcript\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```') @@ -567,6 +575,7 @@ export function MarkdownEditor({ TableBlockExtension, CalendarBlockExtension, EmailBlockExtension, + TranscriptBlockExtension, WikiLink.configure({ onCreate: wikiLinks?.onCreate ? (path) => { diff --git a/apps/x/apps/renderer/src/extensions/calendar-block.tsx b/apps/x/apps/renderer/src/extensions/calendar-block.tsx index f72dc5d4..9f0eec02 100644 --- a/apps/x/apps/renderer/src/extensions/calendar-block.tsx +++ b/apps/x/apps/renderer/src/extensions/calendar-block.tsx @@ -9,12 +9,15 @@ function formatTime(dateStr: string): string { return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) } -function getDateParts(dateStr: string): { day: number; month: string; weekday: string } { +function getDateParts(dateStr: string): { day: number; month: string; weekday: string; isToday: boolean } { const d = new Date(dateStr) + const now = new Date() + const isToday = d.getDate() === now.getDate() && d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear() return { day: d.getDate(), - month: d.toLocaleDateString([], { month: 'long' }), - weekday: d.toLocaleDateString([], { weekday: 'short' }), + month: d.toLocaleDateString([], { month: 'short' }).toUpperCase(), + weekday: d.toLocaleDateString([], { weekday: 'short' }).toUpperCase(), + isToday, } } @@ -62,7 +65,8 @@ interface ResolvedEvent { conferenceLink?: string } -const EVENT_BAR_COLOR = '#7ec8c8' +const GCAL_EVENT_COLOR = '#039be5' +const GCAL_TODAY_COLOR = '#1a73e8' function JoinMeetingSplitButton({ onJoinAndNotes, onNotesOnly }: { onJoinAndNotes: () => void @@ -273,11 +277,8 @@ function CalendarBlockView({ node, deleteNode }: { node: { attrs: Record {parts ? ( <> - {parts.day} -
- {parts.month} - {parts.weekday} -
+ {parts.weekday} + {parts.day} ) : ( ? @@ -288,16 +289,13 @@ function CalendarBlockView({ node, deleteNode }: { node: { attrs: Record e.stopPropagation()} onClick={(e) => { e.stopPropagation(); handleEventClick(event) }} > -
- {event.summary || 'Untitled event'} + {event.summary || '(No title)'}
{getTimeRange(event)} diff --git a/apps/x/apps/renderer/src/extensions/email-block.tsx b/apps/x/apps/renderer/src/extensions/email-block.tsx index 9be8c72c..7356c94c 100644 --- a/apps/x/apps/renderer/src/extensions/email-block.tsx +++ b/apps/x/apps/renderer/src/extensions/email-block.tsx @@ -1,8 +1,9 @@ import { mergeAttributes, Node } from '@tiptap/react' import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' -import { X, Mail, ChevronDown, ExternalLink, Copy, Check, Sparkles, Loader2, MessageSquare } from 'lucide-react' +import { X, Mail, ChevronDown, ExternalLink, Copy, Check, MessageSquare } from 'lucide-react' import { blocks } from '@x/shared' import { useState, useEffect, useRef, useCallback } from 'react' +import { useTheme } from '@/contexts/theme-context' // --- Helpers --- @@ -17,8 +18,10 @@ function formatEmailDate(dateStr: string): string { } } -function getInitials(name: string): string { - return name.split(/\s+/).map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase() +/** Extract just the name part from "Name " format */ +function senderFirstName(from: string): string { + const name = from.replace(/<.*>/, '').trim() + return name.split(/\s+/)[0] || name } declare global { @@ -45,27 +48,15 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: { const hasDraft = !!config?.draft_response const hasPastSummary = !!config?.past_summary - const responseMode = config?.response_mode || 'both' + + const { resolvedTheme } = useTheme() // Local draft state for editing const [draftBody, setDraftBody] = useState(config?.draft_response || '') - const [contextExpanded, setContextExpanded] = useState(false) + const [emailExpanded, setEmailExpanded] = useState(false) const [copied, setCopied] = useState(false) - const [generating, setGenerating] = useState(false) - const [responseSplitOpen, setResponseSplitOpen] = useState(false) - const responseSplitRef = useRef(null) const bodyRef = useRef(null) - // Close split dropdown on outside click - useEffect(() => { - if (!responseSplitOpen) return - const handler = (e: MouseEvent) => { - if (responseSplitRef.current && !responseSplitRef.current.contains(e.target as globalThis.Node)) setResponseSplitOpen(false) - } - document.addEventListener('mousedown', handler) - return () => document.removeEventListener('mousedown', handler) - }, [responseSplitOpen]) - // Sync draft from external changes useEffect(() => { try { @@ -89,53 +80,23 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: { } catch { /* ignore */ } }, [raw, updateAttributes]) - const generateResponse = useCallback(async () => { - if (!config || generating) return - setGenerating(true) - try { - const ipc = (window as unknown as { ipc: { invoke: (channel: string, args: Record) => Promise<{ response?: string }> } }).ipc - // Build context for the agent - let noteContent = `# Email: ${config.subject || 'No subject'}\n\n` - noteContent += `**From:** ${config.from || 'Unknown'}\n` - noteContent += `**Date:** ${config.date || 'Unknown'}\n\n` - noteContent += `## Latest email\n\n${config.latest_email}\n\n` - if (config.past_summary) { - noteContent += `## Earlier conversation summary\n\n${config.past_summary}\n\n` - } - - const result = await ipc.invoke('inline-task:process', { - instruction: `Draft a concise, professional response to this email. Return only the email body text, no subject line or headers.`, - noteContent, - notePath: '', - }) - - if (result.response) { - // Clean up the response — strip any markdown headers the agent may add - const cleaned = result.response.replace(/^#+\s+.*\n*/gm, '').trim() - setDraftBody(cleaned) - // Update the block data to include the draft - const current = JSON.parse(raw) as Record - updateAttributes({ data: JSON.stringify({ ...current, draft_response: cleaned }) }) - } - } catch (err) { - console.error('[email-block] Failed to generate response:', err) - } finally { - setGenerating(false) - } - }, [config, generating, raw, updateAttributes]) - const draftWithAssistant = useCallback(() => { if (!config) return - let prompt = `Help me draft a response to this email` + let prompt = draftBody + ? `Help me refine this draft response to an email` + : `Help me draft a response to this email` if (config.threadId) { prompt += `. Read the full thread at gmail_sync/${config.threadId}.md for context` } prompt += `.\n\n` prompt += `**From:** ${config.from || 'Unknown'}\n` prompt += `**Subject:** ${config.subject || 'No subject'}\n` + if (draftBody) { + prompt += `\n**Current draft:**\n${draftBody}\n` + } window.__pendingEmailDraft = { prompt } window.dispatchEvent(new Event('email-block:draft-with-assistant')) - }, [config]) + }, [config, draftBody]) if (!config) { return ( @@ -152,185 +113,112 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: { ? `https://mail.google.com/mail/u/0/#all/${config.threadId}` : null - // --- Render: Draft mode (draft_response present) --- - if (hasDraft) { - return ( - -
e.stopPropagation()}> - - {/* Draft header */} - {config.to && ( -
-
- To - {config.to} -
- {config.subject && ( -
- Subject - {config.subject} + // Build summary: use explicit summary, or auto-generate from sender + subject + const summary = config.summary + || (config.from && config.subject + ? `${senderFirstName(config.from)} reached out about ${config.subject}` + : config.subject || 'New email') + + return ( + +
e.stopPropagation()}> + + + {/* Header: Email badge */} +
+ + Email +
+ + {/* Summary */} +
{summary}
+ + {/* Expandable email details */} + + + {emailExpanded && ( +
+
+
+
+
+
{config.from || 'Unknown'}
+ {config.date &&
{formatEmailDate(config.date)}
} +
+ {config.subject &&
Subject: {config.subject}
}
- )} +
+
{config.latest_email}
- )} - {/* Editable draft body */} -