From 2190e793a6c98181db36be63cc90e4dd2d3f9a26 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Sat, 21 Mar 2026 09:03:23 +0530 Subject: [PATCH] email block initial --- .../src/components/markdown-editor.tsx | 4 + .../renderer/src/extensions/email-block.tsx | 333 ++++++++++++++++++ apps/x/apps/renderer/src/styles/editor.css | 325 ++++++++++++++++- apps/x/packages/shared/src/blocks.ts | 13 + 4 files changed, 668 insertions(+), 7 deletions(-) create mode 100644 apps/x/apps/renderer/src/extensions/email-block.tsx diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 4fb68c2f..2592dec3 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -14,6 +14,7 @@ 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 { EmailBlockExtension } from '@/extensions/email-block' import { Markdown } from 'tiptap-markdown' import { useEffect, useCallback, useMemo, useRef, useState } from 'react' import { Calendar, ChevronDown, ExternalLink } from 'lucide-react' @@ -152,6 +153,8 @@ function getMarkdownWithBlankLines(editor: Editor): string { 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 === 'emailBlock') { + blocks.push('```email\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```') @@ -563,6 +566,7 @@ export function MarkdownEditor({ ChartBlockExtension, TableBlockExtension, CalendarBlockExtension, + EmailBlockExtension, WikiLink.configure({ onCreate: wikiLinks?.onCreate ? (path) => { diff --git a/apps/x/apps/renderer/src/extensions/email-block.tsx b/apps/x/apps/renderer/src/extensions/email-block.tsx new file mode 100644 index 00000000..084f3ba2 --- /dev/null +++ b/apps/x/apps/renderer/src/extensions/email-block.tsx @@ -0,0 +1,333 @@ +import { mergeAttributes, Node } from '@tiptap/react' +import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' +import { X, Mail, ChevronDown, ExternalLink, Copy, Check, Sparkles, Loader2 } from 'lucide-react' +import { blocks } from '@x/shared' +import { useState, useEffect, useRef, useCallback } from 'react' + +// --- Helpers --- + +function formatEmailDate(dateStr: string): string { + try { + const d = new Date(dateStr) + if (isNaN(d.getTime())) return dateStr + return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) + + ' ' + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + } catch { + return dateStr + } +} + +function getInitials(name: string): string { + return name.split(/\s+/).map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase() +} + +// --- Email Block --- + +function EmailBlockView({ node, deleteNode, updateAttributes }: { + node: { attrs: Record } + deleteNode: () => void + updateAttributes: (attrs: Record) => void +}) { + const raw = node.attrs.data as string + let config: blocks.EmailBlock | null = null + + try { + config = blocks.EmailBlockSchema.parse(JSON.parse(raw)) + } catch { + // fallback below + } + + const hasDraft = !!config?.draft_response + const hasPastSummary = !!config?.past_summary + + // Local draft state for editing + const [draftBody, setDraftBody] = useState(config?.draft_response || '') + const [contextExpanded, setContextExpanded] = useState(false) + const [copied, setCopied] = useState(false) + const [generating, setGenerating] = useState(false) + const bodyRef = useRef(null) + + // Sync draft from external changes + useEffect(() => { + try { + const parsed = blocks.EmailBlockSchema.parse(JSON.parse(raw)) + setDraftBody(parsed.draft_response || '') + } catch { /* ignore */ } + }, [raw]) + + // Auto-resize textarea + useEffect(() => { + if (bodyRef.current) { + bodyRef.current.style.height = 'auto' + bodyRef.current.style.height = bodyRef.current.scrollHeight + 'px' + } + }, [draftBody]) + + const commitDraft = useCallback((newBody: string) => { + try { + const current = JSON.parse(raw) as Record + updateAttributes({ data: JSON.stringify({ ...current, draft_response: newBody }) }) + } 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]) + + if (!config) { + return ( + +
+ + Invalid email block +
+
+ ) + } + + const gmailUrl = config.threadId + ? `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} +
+ )} +
+ )} + {/* Editable draft body */} +