draft email options

This commit is contained in:
Arjun 2026-03-23 23:04:22 +05:30 committed by arkml
parent 2d62824030
commit fa07a75358
4 changed files with 190 additions and 9 deletions

View file

@ -3490,6 +3490,20 @@ function App() {
return () => window.removeEventListener('calendar-block:join-meeting', handler)
}, [])
// Email block: draft with assistant
useEffect(() => {
const handler = () => {
const pending = window.__pendingEmailDraft
if (pending) {
setPresetMessage(pending.prompt)
setIsChatSidebarOpen(true)
window.__pendingEmailDraft = undefined
}
}
window.addEventListener('email-block:draft-with-assistant', handler)
return () => window.removeEventListener('email-block:draft-with-assistant', handler)
}, [])
const ensureWikiFile = useCallback(async (wikiPath: string) => {
const resolvedPath = toKnowledgePath(wikiPath)
if (!resolvedPath) return null

View file

@ -1,6 +1,6 @@
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 { X, Mail, ChevronDown, ExternalLink, Copy, Check, Sparkles, Loader2, MessageSquare } from 'lucide-react'
import { blocks } from '@x/shared'
import { useState, useEffect, useRef, useCallback } from 'react'
@ -21,6 +21,12 @@ function getInitials(name: string): string {
return name.split(/\s+/).map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase()
}
declare global {
interface Window {
__pendingEmailDraft?: { prompt: string }
}
}
// --- Email Block ---
function EmailBlockView({ node, deleteNode, updateAttributes }: {
@ -39,14 +45,27 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
const hasDraft = !!config?.draft_response
const hasPastSummary = !!config?.past_summary
const responseMode = config?.response_mode || 'both'
// 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 [responseSplitOpen, setResponseSplitOpen] = useState(false)
const responseSplitRef = useRef<HTMLDivElement>(null)
const bodyRef = useRef<HTMLTextAreaElement>(null)
// Close split dropdown on outside click
useEffect(() => {
if (!responseSplitOpen) return
const handler = (e: MouseEvent) => {
if (responseSplitRef.current && !responseSplitRef.current.contains(e.target as Node)) setResponseSplitOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [responseSplitOpen])
// Sync draft from external changes
useEffect(() => {
try {
@ -105,6 +124,19 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
}
}, [config, generating, raw, updateAttributes])
const draftWithAssistant = useCallback(() => {
if (!config) return
let prompt = `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`
window.__pendingEmailDraft = { prompt }
window.dispatchEvent(new Event('email-block:draft-with-assistant'))
}, [config])
if (!config) {
return (
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
@ -250,14 +282,56 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
{contextExpanded ? 'Hide' : 'Show'} context
</button>
)}
<button
className="email-block-gmail-btn email-block-generate-btn"
onClick={generateResponse}
disabled={generating}
>
{generating ? <Loader2 size={13} className="email-block-spinner" /> : <Sparkles size={13} />}
{generating ? 'Generating...' : 'Generate response'}
</button>
{responseMode === 'inline' && (
<button
className="email-block-gmail-btn email-block-generate-btn"
onClick={generateResponse}
disabled={generating}
>
{generating ? <Loader2 size={13} className="email-block-spinner" /> : <Sparkles size={13} />}
{generating ? 'Generating...' : 'Generate response'}
</button>
)}
{responseMode === 'assistant' && (
<button
className="email-block-gmail-btn email-block-generate-btn"
onClick={draftWithAssistant}
>
<MessageSquare size={13} />
Draft with assistant
</button>
)}
{responseMode === 'both' && (
<div className="email-block-response-split" ref={responseSplitRef}>
<button
className="email-block-split-main"
onClick={generateResponse}
disabled={generating}
>
{generating ? <Loader2 size={13} className="email-block-spinner" /> : <Sparkles size={13} />}
{generating ? 'Generating...' : 'Generate response'}
</button>
<button
className={`email-block-split-chevron ${responseSplitOpen ? 'email-block-split-chevron-open' : ''}`}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); setResponseSplitOpen(!responseSplitOpen) }}
>
<ChevronDown size={12} />
</button>
{responseSplitOpen && (
<div className="email-block-split-dropdown">
<button
className="email-block-split-option"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); setResponseSplitOpen(false); draftWithAssistant() }}
>
<MessageSquare size={13} />
Draft with assistant
</button>
</div>
)}
</div>
)}
{gmailUrl && (
<button
className="email-block-gmail-btn"

View file

@ -1298,6 +1298,98 @@
animation: email-block-spin 1s linear infinite;
}
/* Email block split button (generate/assistant) */
.tiptap-editor .ProseMirror .email-block-response-split {
position: relative;
display: inline-flex;
align-items: stretch;
margin-top: 8px;
}
.tiptap-editor .ProseMirror .email-block-response-split > button {
box-sizing: border-box;
}
.tiptap-editor .ProseMirror .email-block-split-main {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 8px 5px 12px;
font-size: 12px;
font-weight: 500;
color: var(--primary);
background: color-mix(in srgb, var(--foreground) 5%, transparent);
border: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
border-right: none;
border-radius: 6px 0 0 6px;
cursor: pointer;
transition: background-color 0.12s ease;
}
.tiptap-editor .ProseMirror .email-block-split-main:hover:not(:disabled) {
background: color-mix(in srgb, var(--foreground) 10%, transparent);
}
.tiptap-editor .ProseMirror .email-block-split-main:disabled {
opacity: 0.6;
cursor: default;
}
.tiptap-editor .ProseMirror .email-block-split-chevron {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 5px 6px;
color: color-mix(in srgb, var(--foreground) 60%, transparent);
background: color-mix(in srgb, var(--foreground) 5%, transparent);
border: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
border-left: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
border-radius: 0 6px 6px 0;
cursor: pointer;
transition: background-color 0.12s ease;
}
.tiptap-editor .ProseMirror .email-block-split-chevron:hover {
background: color-mix(in srgb, var(--foreground) 10%, transparent);
}
.tiptap-editor .ProseMirror .email-block-split-chevron-open {
border-radius: 0 6px 0 0;
border-bottom-color: transparent;
}
.tiptap-editor .ProseMirror .email-block-split-dropdown {
position: absolute;
top: calc(100% - 1px);
right: 0;
z-index: 50;
background: color-mix(in srgb, var(--foreground) 5%, transparent);
border: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
border-top: none;
border-radius: 0 0 6px 6px;
}
.tiptap-editor .ProseMirror .email-block-split-option {
display: flex;
align-items: center;
gap: 5px;
white-space: nowrap;
padding: 5px 12px;
font-size: 12px;
font-weight: 500;
color: color-mix(in srgb, var(--foreground) 60%, transparent);
background: none;
border: none;
border-radius: 0 0 6px 6px;
cursor: pointer;
transition: background-color 0.12s ease;
}
.tiptap-editor .ProseMirror .email-block-split-option:hover {
background: color-mix(in srgb, var(--foreground) 10%, transparent);
color: var(--foreground);
}
/* Email draft block */
.tiptap-editor .ProseMirror .email-draft-block-header {
display: flex;

View file

@ -70,6 +70,7 @@ export const EmailBlockSchema = z.object({
latest_email: z.string(),
past_summary: z.string().optional(),
draft_response: z.string().optional(),
response_mode: z.enum(['inline', 'assistant', 'both']).optional(),
});
export type EmailBlock = z.infer<typeof EmailBlockSchema>;