mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-18 21:21:11 +02:00
draft email options
This commit is contained in:
parent
2d62824030
commit
fa07a75358
4 changed files with 190 additions and 9 deletions
|
|
@ -3490,6 +3490,20 @@ function App() {
|
||||||
return () => window.removeEventListener('calendar-block:join-meeting', handler)
|
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 ensureWikiFile = useCallback(async (wikiPath: string) => {
|
||||||
const resolvedPath = toKnowledgePath(wikiPath)
|
const resolvedPath = toKnowledgePath(wikiPath)
|
||||||
if (!resolvedPath) return null
|
if (!resolvedPath) return null
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { mergeAttributes, Node } from '@tiptap/react'
|
import { mergeAttributes, Node } from '@tiptap/react'
|
||||||
import { ReactNodeViewRenderer, NodeViewWrapper } 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 { blocks } from '@x/shared'
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
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()
|
return name.split(/\s+/).map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__pendingEmailDraft?: { prompt: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Email Block ---
|
// --- Email Block ---
|
||||||
|
|
||||||
function EmailBlockView({ node, deleteNode, updateAttributes }: {
|
function EmailBlockView({ node, deleteNode, updateAttributes }: {
|
||||||
|
|
@ -39,14 +45,27 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
|
||||||
|
|
||||||
const hasDraft = !!config?.draft_response
|
const hasDraft = !!config?.draft_response
|
||||||
const hasPastSummary = !!config?.past_summary
|
const hasPastSummary = !!config?.past_summary
|
||||||
|
const responseMode = config?.response_mode || 'both'
|
||||||
|
|
||||||
// Local draft state for editing
|
// Local draft state for editing
|
||||||
const [draftBody, setDraftBody] = useState(config?.draft_response || '')
|
const [draftBody, setDraftBody] = useState(config?.draft_response || '')
|
||||||
const [contextExpanded, setContextExpanded] = useState(false)
|
const [contextExpanded, setContextExpanded] = useState(false)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const [generating, setGenerating] = useState(false)
|
const [generating, setGenerating] = useState(false)
|
||||||
|
const [responseSplitOpen, setResponseSplitOpen] = useState(false)
|
||||||
|
const responseSplitRef = useRef<HTMLDivElement>(null)
|
||||||
const bodyRef = useRef<HTMLTextAreaElement>(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
|
// Sync draft from external changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -105,6 +124,19 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
|
||||||
}
|
}
|
||||||
}, [config, generating, raw, 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) {
|
if (!config) {
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
|
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
|
||||||
|
|
@ -250,14 +282,56 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
|
||||||
{contextExpanded ? 'Hide' : 'Show'} context
|
{contextExpanded ? 'Hide' : 'Show'} context
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
{responseMode === 'inline' && (
|
||||||
className="email-block-gmail-btn email-block-generate-btn"
|
<button
|
||||||
onClick={generateResponse}
|
className="email-block-gmail-btn email-block-generate-btn"
|
||||||
disabled={generating}
|
onClick={generateResponse}
|
||||||
>
|
disabled={generating}
|
||||||
{generating ? <Loader2 size={13} className="email-block-spinner" /> : <Sparkles size={13} />}
|
>
|
||||||
{generating ? 'Generating...' : 'Generate response'}
|
{generating ? <Loader2 size={13} className="email-block-spinner" /> : <Sparkles size={13} />}
|
||||||
</button>
|
{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 && (
|
{gmailUrl && (
|
||||||
<button
|
<button
|
||||||
className="email-block-gmail-btn"
|
className="email-block-gmail-btn"
|
||||||
|
|
|
||||||
|
|
@ -1298,6 +1298,98 @@
|
||||||
animation: email-block-spin 1s linear infinite;
|
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 */
|
/* Email draft block */
|
||||||
.tiptap-editor .ProseMirror .email-draft-block-header {
|
.tiptap-editor .ProseMirror .email-draft-block-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ export const EmailBlockSchema = z.object({
|
||||||
latest_email: z.string(),
|
latest_email: z.string(),
|
||||||
past_summary: z.string().optional(),
|
past_summary: z.string().optional(),
|
||||||
draft_response: z.string().optional(),
|
draft_response: z.string().optional(),
|
||||||
|
response_mode: z.enum(['inline', 'assistant', 'both']).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type EmailBlock = z.infer<typeof EmailBlockSchema>;
|
export type EmailBlock = z.infer<typeof EmailBlockSchema>;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue