feat: compose new email with contact autocomplete and AI drafting (#616)

* feat: compose new email with contact autocomplete and AI drafting

- Add a compose-new-email box to the email view with a recipient field
  that autocompletes from Gmail contacts (keyboard navigation, match
  highlighting, avatar chips)
- Build contact indices in core: gmail_sent_contacts syncs the SENT
  label via the Gmail API for full coverage of people you've emailed,
  with gmail_contacts as an instant local-snapshot fallback; both are
  pre-warmed at startup so the first keystroke is instant
- Add generateOneShot() one-shot text generation for the composer's
  "write with AI", resolving to the active default model/provider
- Add getAccountName() (parsed from a recent SENT message's From
  header, no extra OAuth scope) so AI drafts sign off with the real name
- New IPC channels: gmail:searchContacts, gmail:getAccountName,
  llm:generate, llm:getDefaultModel

* feat: attachments, undo/redo, and unified compose for new emails

- Merge ComposeNewBox into ComposeBox via a new 'new' mode, memoizing the
  component so inbox sync ticks no longer jank the open composer.
- Add file attachments: stage files in the renderer (25MB cap), pass raw
  base64 over IPC, and build a multipart/mixed MIME on send.
- Add undo/redo buttons to the compose toolbar.
- Single Write/Edit AI bar that generates a draft, then iteratively rewrites
  it; drop the hardcoded Gemini Flash model and use the default Copilot model.
- Suppress inbox reloads while the compose-new modal is open.
- Log llm:generate provider/model/output for debugging.

* fix: remove redundant Subject placeholder in composer

The subject row already has a 'Subject' gutter label, so the input's
placeholder repeated the word — an empty field read 'Subject' twice.
Drop the placeholder to match the To/Cc/Bcc fields.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Harshvardhan Vatsa 2026-06-19 00:14:00 +05:30 committed by GitHub
parent 3fe7f307ab
commit c38ddef93f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 772 additions and 62 deletions

View file

@ -26,7 +26,8 @@ import { RunEvent } from '@x/shared/dist/runs.js';
import { ServiceEvent } from '@x/shared/dist/service-events.js';
import container from '@x/core/dist/di/container.js';
import { listOnboardingModels } from '@x/core/dist/models/models-dev.js';
import { testModelConnection } from '@x/core/dist/models/models.js';
import { testModelConnection, generateOneShot } from '@x/core/dist/models/models.js';
import { getDefaultModelAndProvider } from '@x/core/dist/models/defaults.js';
import { isSignedIn } from '@x/core/dist/account/account.js';
import { listGatewayModels } from '@x/core/dist/models/gateway.js';
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
@ -67,7 +68,7 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
import { searchContacts as searchGmailContacts, warmContactIndex } from '@x/core/dist/knowledge/gmail_contacts.js';
import { searchSentContacts, warmSentContacts } from '@x/core/dist/knowledge/gmail_sent_contacts.js';
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
@ -742,6 +743,9 @@ export function setupIpcHandlers() {
'gmail:getAccountEmail': async () => {
return { email: await getAccountEmail() };
},
'gmail:getAccountName': async () => {
return { name: await getAccountName() };
},
'gmail:archiveThread': async (_event, args) => {
return archiveThread(args.threadId);
},
@ -848,6 +852,15 @@ export function setupIpcHandlers() {
'models:test': async (_event, args) => {
return await testModelConnection(args.provider, args.model);
},
'llm:getDefaultModel': async () => {
return await getDefaultModelAndProvider();
},
'llm:generate': async (_event, args) => {
console.log(`[llm:generate] requested provider=${args.provider ?? '(default)'} model=${args.model ?? '(default)'}`);
const result = await generateOneShot(args);
console.log(`[llm:generate] -> provider=${result.provider ?? '?'} model=${result.model ?? '?'} chars=${result.text?.length ?? 0}${result.error ? ` error=${result.error}` : ''}`);
return result;
},
'models:saveConfig': async (_event, args) => {
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
await repo.setConfig(args);

View file

@ -160,6 +160,13 @@
border-bottom: 1px solid var(--gm-border);
}
.gmail-topbar-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.gmail-search {
display: flex;
align-items: center;
@ -707,6 +714,112 @@
border-color: var(--gm-border-strong);
}
/* Standalone "new email" composer — centered modal popup */
.gmail-compose-overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
background: rgba(0, 0, 0, 0.32);
}
.gmail-compose-modal {
display: flex;
flex-direction: column;
width: min(840px, 100%);
height: min(720px, calc(100vh - 64px));
max-height: calc(100vh - 64px);
border: 1px solid var(--gm-border-strong);
border-radius: 10px;
overflow: hidden;
background: var(--gm-bg-card);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35);
}
.gmail-compose-modal-header {
display: flex;
align-items: center;
gap: 10px;
height: 40px;
padding: 0 8px 0 14px;
background: var(--gm-bg-input);
color: var(--gm-text-body);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.01em;
text-transform: uppercase;
}
.gmail-compose-modal-header > span {
flex: 1;
}
.gmail-compose-modal .gmail-compose-editor {
flex: 1;
min-height: 160px;
max-height: none;
padding: 0 14px;
}
.gmail-compose-ai-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--gm-border);
}
.gmail-compose-ai-input {
flex: 1;
min-width: 0;
height: 30px;
padding: 0 10px;
border: 1px solid var(--gm-border-strong);
border-radius: 6px;
outline: none;
background: var(--gm-bg-input);
color: var(--gm-text);
font: inherit;
font-size: 12px;
}
.gmail-compose-ai-presets {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 12px 10px;
border-bottom: 1px solid var(--gm-border);
}
.gmail-compose-ai-presets button {
height: 24px;
padding: 0 10px;
border: 1px solid var(--gm-border-strong);
border-radius: 999px;
background: var(--gm-bg-pill);
color: var(--gm-text-muted);
font: inherit;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
}
.gmail-compose-ai-presets button:hover:not(:disabled) {
background: var(--gm-bg-pill-hover);
border-color: var(--gm-accent);
color: var(--gm-accent);
}
.gmail-compose-ai-presets button:disabled,
.gmail-compose-ai-input:disabled {
opacity: 0.5;
cursor: default;
}
.gmail-compose-card {
max-width: 720px;
margin-left: 40px;
@ -987,7 +1100,10 @@
gap: 2px;
flex: 1;
min-width: 0;
justify-content: center;
justify-content: flex-start;
padding-left: 10px;
margin-left: 2px;
border-left: 1px solid var(--gm-border-strong);
}
.gmail-compose-link-popover {
@ -1059,11 +1175,16 @@
transition: background 120ms ease, color 120ms ease;
}
.gmail-compose-tool:hover {
.gmail-compose-tool:hover:not(:disabled) {
background: var(--gm-bg-pill-hover);
color: var(--gm-text);
}
.gmail-compose-tool:disabled {
opacity: 0.4;
cursor: default;
}
.gmail-compose-tool.is-active {
background: var(--gm-bg-pill-hover);
color: var(--gm-accent);
@ -1154,6 +1275,52 @@
pointer-events: none;
}
.gmail-compose-attachments {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 12px 0;
}
.gmail-compose-attachment {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 240px;
padding: 4px 8px;
border: 1px solid var(--gm-border);
border-radius: 6px;
background: var(--gm-bg-pill);
font-size: 12px;
color: var(--gm-text);
}
.gmail-compose-attachment-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gmail-compose-attachment-size {
color: var(--gm-text-muted);
flex-shrink: 0;
}
.gmail-compose-attachment-remove {
border: none;
background: transparent;
color: var(--gm-text-muted);
cursor: pointer;
font-size: 15px;
line-height: 1;
padding: 0 0 0 2px;
flex-shrink: 0;
}
.gmail-compose-attachment-remove:hover {
color: var(--gm-text);
}
.gmail-compose-actions {
display: flex;
align-items: center;

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, RefreshCw, Reply, ReplyAll, Search, Send, Sparkles, Strikethrough, Trash2 } from 'lucide-react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, Redo2, RefreshCw, Reply, ReplyAll, Search, Send, Sparkles, SquarePen, Strikethrough, Trash2, Undo2 } from 'lucide-react'
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
@ -258,6 +258,15 @@ function escapeHtml(text: string): string {
.replace(/'/g, '&#39;')
}
// Convert AI-generated plain text into the simple paragraph HTML the Tiptap
// editor expects (blank lines → paragraphs, single newlines → <br />).
function plainTextToHtml(text: string): string {
return text
.split(/\n{2,}/)
.map((para) => `<p>${escapeHtml(para.trim()).replace(/\n/g, '<br />')}</p>`)
.join('')
}
function splitPlainTextQuote(text: string): { visible: string; quoted: string | null } {
const re = /(?:^|\n)On\s+.+?\swrote:\s*(?:\n|$)/
const match = re.exec(text)
@ -514,7 +523,7 @@ function MessageAttachments({ attachments }: { attachments: NonNullable<GmailThr
)
}
type ComposeMode = 'reply' | 'replyAll' | 'forward'
type ComposeMode = 'reply' | 'replyAll' | 'forward' | 'new'
function ComposeToolbarButton({
editor,
@ -550,6 +559,29 @@ function ComposeToolbarButton({
function ComposeToolbar({ editor, onOpenLink }: { editor: Editor; onOpenLink: () => void }) {
return (
<div className="gmail-compose-toolbar">
<button
type="button"
className="gmail-compose-tool"
onMouseDown={(event) => event.preventDefault()}
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
aria-label="Undo"
title="Undo"
>
<Undo2 size={14} />
</button>
<button
type="button"
className="gmail-compose-tool"
onMouseDown={(event) => event.preventDefault()}
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
aria-label="Redo"
title="Redo"
>
<Redo2 size={14} />
</button>
<span className="gmail-compose-tool-sep" />
<ComposeToolbarButton
editor={editor}
command={() => editor.chain().focus().toggleBold().run()}
@ -866,20 +898,76 @@ function RecipientField({
)
}
function ComposeBox({
const AI_GENERATE_SYSTEM =
'You write complete emails. Given an instruction, produce a subject line and a body. ' +
'Respond in EXACTLY this format and nothing else:\n' +
'Subject: <a concise, specific subject line>\n' +
'\n' +
'<the email body as plain text>\n' +
'Do not use markdown. Do not add any commentary, labels, or surrounding quotes. ' +
'When recipient names are provided, address them naturally (e.g. "Hi <first name>,"). ' +
'When the sender\'s name is provided, sign off with it; otherwise omit the sign-off name ' +
'(never write a placeholder like "[Your Name]").'
const AI_REWRITE_SYSTEM =
'You rewrite emails. Given the current subject and body plus an edit instruction, ' +
'produce the revised subject line and body. Keep the subject if it still fits, or ' +
'refine it so it matches the rewritten body. Respond in EXACTLY this format and nothing else:\n' +
'Subject: <the subject line>\n' +
'\n' +
'<the rewritten email body as plain text>\n' +
'Do not use markdown. Do not add any commentary, labels, or surrounding quotes. ' +
'Preserve the existing sign-off; do not invent placeholder names like "[Your Name]".'
// Split AI output of the form "Subject: …\n\n<body>" into its parts. If no
// subject line is present, the whole text is treated as the body.
function parseGeneratedEmail(text: string): { subject: string | null; body: string } {
const match = text.match(/^\s*Subject:\s*(.+?)(?:\r?\n|$)/i)
if (match) {
const subject = match[1].trim()
const body = text.slice(match.index! + match[0].length).replace(/^\s+/, '')
return { subject, body }
}
return { subject: null, body: text }
}
// Guarantee the sender's name signs off the email. If the model already ended
// with the name (e.g. "Best,\nHarsh"), leave it; otherwise append it.
function ensureSignature(body: string, name: string): string {
const signer = name.trim()
if (!signer) return body
const trimmed = body.replace(/\s+$/, '')
// Check the last couple of lines so we don't double up an existing sign-off.
const tail = trimmed.split('\n').slice(-2).join('\n').toLowerCase()
if (tail.includes(signer.toLowerCase())) return trimmed
return `${trimmed}\n\n${signer}`
}
const TONE_PRESETS: Array<{ key: string; label: string; instruction: string }> = [
{ key: 'formal', label: 'Formal', instruction: 'Rewrite this email to be more formal and professional.' },
{ key: 'casual', label: 'Casual', instruction: 'Rewrite this email to be more casual and friendly.' },
{ key: 'shorter', label: 'Shorter', instruction: 'Rewrite this email to be more concise, keeping the key points.' },
{ key: 'longer', label: 'Longer', instruction: 'Rewrite this email to be more detailed and thorough.' },
]
// Composer for replies, forwards, and (mode 'new') from-scratch emails. With a
// thread it renders as an inline card under the thread; in 'new' mode it has no
// thread and renders as a centered modal with the AI writing bar.
const ComposeBox = memo(function ComposeBox({
mode,
thread,
selfEmail,
selfEmail = '',
onClose,
}: {
mode: ComposeMode
thread: GmailThread
selfEmail: string
thread?: GmailThread
selfEmail?: string
onClose: () => void
}) {
const latest = latestMessage(thread)
const isNew = mode === 'new'
const latest = thread ? latestMessage(thread) : undefined
const initialRecipients = useMemo(
() => buildRecipients(mode, thread, selfEmail),
() => (thread ? buildRecipients(mode, thread, selfEmail) : { to: [], cc: [] }),
[mode, thread, selfEmail],
)
@ -888,10 +976,11 @@ function ComposeBox({
const [bccList, setBccList] = useState<string[]>([])
const [showCc, setShowCc] = useState<boolean>(initialRecipients.cc.length > 0)
const [showBcc, setShowBcc] = useState<boolean>(false)
const [subject, setSubject] = useState<string>(() => composeSubject(mode, thread.subject))
const modeLabel = mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply all' : 'Reply'
const [subject, setSubject] = useState<string>(() => (thread ? composeSubject(mode, thread.subject) : ''))
const modeLabel = isNew ? 'New message' : mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply all' : 'Reply'
const initialContent = useMemo(() => {
if (!thread) return ''
if (mode === 'forward') return buildForwardedContent(thread)
// Gmail-side draft (user's own work) wins over the AI-generated draft.
const source = stripQuotedReplyText(thread.gmail_draft || thread.draft_response || '')
@ -907,7 +996,7 @@ function ComposeBox({
StarterKit.configure({ link: false }),
Link.configure({ openOnClick: false, autolink: true }),
Placeholder.configure({
placeholder: mode === 'forward' ? 'Write a message…' : 'Write your reply…',
placeholder: isNew || mode === 'forward' ? 'Write a message…' : 'Write your reply…',
}),
],
editorProps: {
@ -959,13 +1048,176 @@ function ComposeBox({
if (editor && sel) editor.chain().focus().setTextSelection(sel).run()
}
// The signed-in account's display name, used to sign off AI-generated emails.
const [selfName, setSelfName] = useState<string>('')
useEffect(() => {
if (!isNew) return
let cancelled = false
window.ipc.invoke('gmail:getAccountName', {})
.then((res) => { if (!cancelled && res?.name) setSelfName(res.name) })
.catch(() => {})
return () => { cancelled = true }
}, [isNew])
const [aiPrompt, setAiPrompt] = useState('')
const [generating, setGenerating] = useState(false)
// Once a draft has been generated, show a follow-up bar for iterative edits
// ("add a line about…", "remove the last paragraph", etc.). It hides again if
// the draft is emptied (e.g. undone), tracked via hasContent below.
const [hasGenerated, setHasGenerated] = useState(false)
const [hasContent, setHasContent] = useState(false)
// Keep hasContent in sync with the editor across typing, undo/redo, and clears.
useEffect(() => {
if (!editor) return
const sync = () => setHasContent(!editor.isEmpty)
sync()
editor.on('update', sync)
return () => { editor.off('update', sync) }
}, [editor])
// Clearing the body reverts the AI control to its "Write" state and drops the
// generated subject, so an emptied composer behaves like a fresh one. The
// hasGenerated guard avoids wiping a subject typed before any generation.
useEffect(() => {
if (hasGenerated && !hasContent) {
setHasGenerated(false)
setSubject('')
}
}, [hasGenerated, hasContent])
const runAi = async (instruction: string, aiMode: 'generate' | 'rewrite') => {
if (!editor || generating) return
const current = editor.getText().trim()
let prompt: string
let system: string
if (aiMode === 'generate') {
if (!instruction.trim()) { toast('Describe what to write.', 'error'); return }
system = AI_GENERATE_SYSTEM
const ctx: string[] = []
// Use the recipients' names (from the contacts picker) so the AI can
// address them naturally; fall back to the address when there's no name.
const recipientNames = toList
.map((token) => {
const name = extractName(token)
return name && name !== 'Unknown' ? name : extractAddress(token)
})
.filter(Boolean)
if (recipientNames.length) ctx.push(`Recipient(s): ${recipientNames.join(', ')}`)
if (selfName) ctx.push(`Sender's name (sign off as this): ${selfName}`)
if (subject.trim()) ctx.push(`Desired subject hint: ${subject.trim()}`)
if (current) ctx.push(`Existing draft (revise or build on it):\n${current}`)
prompt = `${ctx.length ? ctx.join('\n') + '\n\n' : ''}Instruction: ${instruction.trim()}`
} else {
if (!instruction.trim()) { toast('Describe the edit to make.', 'error'); return }
if (!current) { toast('Write something first.', 'error'); return }
system = AI_REWRITE_SYSTEM
const subjectLine = subject.trim() ? `Subject: ${subject.trim()}\n\n` : ''
prompt = `Instruction: ${instruction}\n\n---\n${subjectLine}${current}`
}
setGenerating(true)
try {
// Draft through Copilot: no model override, so the backend resolves the
// same default model/provider the Copilot chat uses (models.json).
const res = await window.ipc.invoke('llm:generate', { prompt, system })
if (res.error || !res.text) {
toast(res.error || 'No text was generated.', 'error')
return
}
// Replace via a tracked transaction (selectAll + insertContent) so the AI
// draft lands in the editor's undo history and the toolbar's Undo reverts it.
if (aiMode === 'generate') {
const { subject: generatedSubject, body } = parseGeneratedEmail(res.text)
if (generatedSubject) setSubject(generatedSubject)
// Always sign off with the account name, even if the model omitted it.
const signed = ensureSignature(body, selfName)
editor.chain().focus().selectAll().insertContent(plainTextToHtml(signed)).run()
setHasGenerated(true)
} else {
// Rewrites also regenerate the subject so it stays in sync with the body.
const { subject: rewrittenSubject, body } = parseGeneratedEmail(res.text)
if (rewrittenSubject) setSubject(rewrittenSubject)
editor.chain().focus().selectAll().insertContent(plainTextToHtml(body)).run()
}
} catch (err) {
toast(`Generation failed: ${err instanceof Error ? err.message : String(err)}`, 'error')
} finally {
setGenerating(false)
}
}
// The single Write/Edit bar: generate a fresh draft until one exists, then
// switch to rewriting it. Clears the prompt after a run kicks off.
const runAiBar = async () => {
await runAi(aiPrompt, hasGenerated ? 'rewrite' : 'generate')
setAiPrompt('')
}
// Attachments staged for this message. contentBase64 is the raw file bytes,
// read in the renderer; the main process wraps them into the MIME on send.
const [attachments, setAttachments] = useState<
Array<{ id: string; filename: string; mimeType: string; size: number; contentBase64: string }>
>([])
const fileInputRef = useRef<HTMLInputElement>(null)
// Gmail rejects messages over ~25MB; base64 inflates bytes by ~33%.
const MAX_TOTAL_BYTES = 25 * 1024 * 1024
// Read a file's bytes as raw base64 (the part after the data: URL prefix).
const readAsBase64 = (file: File) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(reader.error ?? new Error('read failed'))
reader.onload = () => {
const result = String(reader.result)
const comma = result.indexOf(',')
resolve(comma >= 0 ? result.slice(comma + 1) : result)
}
reader.readAsDataURL(file)
})
const addFiles = async (files: FileList | null) => {
if (!files || files.length === 0) return
const staged: typeof attachments = []
for (const file of Array.from(files)) {
try {
staged.push({
id: `${file.name}-${file.size}-${file.lastModified}`,
filename: file.name,
mimeType: file.type || 'application/octet-stream',
size: file.size,
contentBase64: await readAsBase64(file),
})
} catch {
toast(`Could not read ${file.name}.`, 'error')
}
}
setAttachments((prev) => {
const merged = [...prev]
for (const item of staged) {
if (!merged.some((a) => a.id === item.id)) merged.push(item)
}
const total = merged.reduce((sum, a) => sum + a.size, 0)
if (total > MAX_TOTAL_BYTES) {
toast('Attachments exceed the 25MB limit.', 'error')
return prev
}
return merged
})
}
const removeAttachment = (id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id))
}
const [sending, setSending] = useState(false)
const sendInGmail = async () => {
if (!editor || sending) return
const html = editor.getHTML()
const text = editor.getText().trim()
if (!text) {
toast('Draft is empty.', 'error')
toast(isNew ? 'Message is empty.' : 'Draft is empty.', 'error')
return
}
@ -975,25 +1227,29 @@ function ComposeBox({
}
// Build References chain from all known message ids (newest last).
const messageIds = thread.messages
const messageIds = (thread?.messages ?? [])
.map((m) => m.messageIdHeader)
.filter((v): v is string => Boolean(v))
const references = messageIds.join(' ')
const inReplyTo = latest?.messageIdHeader
const isForward = mode === 'forward'
// Only replies stay on the thread; forwards and new emails start fresh.
const isThreaded = Boolean(thread) && mode !== 'forward' && !isNew
setSending(true)
try {
const result = await window.ipc.invoke('gmail:sendReply', {
threadId: isForward ? undefined : thread.threadId,
threadId: isThreaded ? thread?.threadId : undefined,
to: toList.join(', '),
cc: ccList.length ? ccList.join(', ') : undefined,
bcc: bccList.length ? bccList.join(', ') : undefined,
subject: subject.trim() || composeSubject(mode, thread.subject),
subject: subject.trim() || (thread ? composeSubject(mode, thread.subject) : '(No subject)'),
bodyHtml: html,
bodyText: text,
inReplyTo: isForward ? undefined : inReplyTo,
references: isForward ? undefined : references || undefined,
inReplyTo: isThreaded ? inReplyTo : undefined,
references: isThreaded ? references || undefined : undefined,
attachments: attachments.length
? attachments.map(({ filename, mimeType, contentBase64 }) => ({ filename, mimeType, contentBase64 }))
: undefined,
})
if (result.error) {
toast(`Send failed: ${result.error}`, 'error')
@ -1009,7 +1265,7 @@ function ComposeBox({
}
const refineWithCopilot = () => {
if (!editor) return
if (!editor || !thread) return
const currentDraft = editor.getText().trim()
const threadSubject = thread.subject || '(No subject)'
@ -1039,17 +1295,25 @@ function ComposeBox({
window.dispatchEvent(new Event('email-block:draft-with-assistant'))
}
return (
<div className="gmail-compose-card">
<div className="gmail-compose-header">
const card = (
<div
className={isNew ? 'gmail-compose-modal' : 'gmail-compose-card'}
onClick={isNew ? (event) => event.stopPropagation() : undefined}
>
<div className={isNew ? 'gmail-compose-modal-header' : 'gmail-compose-header'}>
<span>{modeLabel}</span>
<button type="button" onClick={onClose} aria-label="Close compose">×</button>
<button
type="button"
className={isNew ? 'gmail-icon-button' : undefined}
onClick={onClose}
aria-label="Close compose"
>×</button>
</div>
<RecipientField
label="To"
value={toList}
onChange={setToList}
autoFocus={mode === 'forward'}
autoFocus={isNew || mode === 'forward'}
trailing={
<div className="gmail-recipient-toggles">
{!showCc && <button type="button" onClick={() => setShowCc(true)}>Cc</button>}
@ -1059,18 +1323,83 @@ function ComposeBox({
/>
{showCc && <RecipientField label="Cc" value={ccList} onChange={setCcList} />}
{showBcc && <RecipientField label="Bcc" value={bccList} onChange={setBccList} />}
{mode === 'forward' && (
{isNew && (
<>
<div className="gmail-compose-ai-bar">
<input
className="gmail-compose-ai-input"
value={aiPrompt}
onChange={(event) => setAiPrompt(event.target.value)}
placeholder={hasGenerated
? 'Edit the draft (e.g. add a line about…, remove the last paragraph)…'
: 'Describe the email and let AI write it…'}
disabled={generating}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void runAiBar()
}
}}
/>
<button
type="button"
className="gmail-refine-button"
onClick={() => { void runAiBar() }}
disabled={generating}
title={hasGenerated ? 'Apply this edit to the draft' : 'Write a draft with AI'}
>
{generating ? <LoaderIcon size={15} className="animate-spin" /> : <Sparkles size={15} />}
{generating
? (hasGenerated ? 'Editing…' : 'Writing…')
: (hasGenerated ? 'Edit' : 'Write')}
</button>
</div>
<div className="gmail-compose-ai-presets">
<button type="button" onClick={() => { void runAi('Improve the clarity, grammar, and flow of this email while preserving its meaning.', 'rewrite') }} disabled={generating}>Improve</button>
{TONE_PRESETS.map((preset) => (
<button key={preset.key} type="button" onClick={() => { void runAi(preset.instruction, 'rewrite') }} disabled={generating}>{preset.label}</button>
))}
</div>
</>
)}
{(isNew || mode === 'forward') && (
<div className="gmail-compose-line">
<span className="gmail-compose-label">Subject</span>
<input
className="gmail-compose-subject-input"
value={subject}
onChange={(event) => setSubject(event.target.value)}
placeholder="Subject"
/>
</div>
)}
<EditorContent editor={editor} className="gmail-compose-editor" />
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={(event) => {
void addFiles(event.target.value ? event.currentTarget.files : null)
event.currentTarget.value = ''
}}
/>
{attachments.length > 0 && (
<div className="gmail-compose-attachments">
{attachments.map((att) => (
<div key={att.id} className="gmail-compose-attachment" title={att.filename}>
<Paperclip size={13} />
<span className="gmail-compose-attachment-name">{att.filename}</span>
<span className="gmail-compose-attachment-size">{formatAttachmentSize(att.size)}</span>
<button
type="button"
className="gmail-compose-attachment-remove"
onClick={() => removeAttachment(att.id)}
aria-label={`Remove ${att.filename}`}
>×</button>
</div>
))}
</div>
)}
{linkOpen && (
<div className="gmail-compose-link-popover" onMouseDown={(event) => event.preventDefault()}>
<input
@ -1099,7 +1428,7 @@ function ComposeBox({
className="gmail-send-button"
onClick={() => { void sendInGmail() }}
disabled={sending}
title="Send this reply via Gmail"
title={isNew ? 'Send this email via Gmail' : 'Send this reply via Gmail'}
>
{sending ? <LoaderIcon size={15} className="animate-spin" /> : <Send size={15} />}
{sending ? 'Sending…' : 'Send'}
@ -1107,19 +1436,40 @@ function ComposeBox({
<button
type="button"
className="gmail-refine-button"
onClick={refineWithCopilot}
title="Refine this draft with Copilot"
onClick={() => fileInputRef.current?.click()}
disabled={sending}
title="Attach files"
>
<Sparkles size={15} />
Refine
<Paperclip size={15} />
Attach
</button>
{thread && (
<button
type="button"
className="gmail-refine-button"
onClick={refineWithCopilot}
title="Refine this draft with Copilot"
>
<Sparkles size={15} />
Refine
</button>
)}
</div>
{editor && <ComposeToolbar editor={editor} onOpenLink={openLink} />}
<button type="button" className="gmail-compose-link" onClick={onClose}>Discard</button>
</div>
</div>
)
}
if (isNew) {
return (
<div className="gmail-compose-overlay" onClick={onClose}>
{card}
</div>
)
}
return card
})
function ThreadDetail({
thread,
@ -1301,6 +1651,9 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
const [refreshing, setRefreshing] = useState(!hadPersistedDataOnMount.current)
const [error, setError] = useState<string | null>(null)
const [query, setQuery] = useState('')
const [composeOpen, setComposeOpen] = useState(false)
// Stable so the open composer isn't re-rendered on every inbox sync tick.
const closeCompose = useCallback(() => setComposeOpen(false), [])
// Gmail sync uses the native Google OAuth connection.
const [emailConnection, setEmailConnection] = useState<GmailConnectionStatus | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
@ -1526,12 +1879,18 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
// when files change. Throttled to at most one reload per ~3s so a burst of
// backend writes (sync processing many threads sequentially) coalesces into
// a small number of in-place updates rather than a flicker storm.
// Suppressed while a thread is open (composing/reading); deferred until close.
// Suppressed while a thread is open (reading/replying) or the compose-new
// modal is open; deferred until whichever is open closes. A reload replaces
// the threads array and re-renders the whole inbox list (and any mounted
// ThreadDetail iframes) on the main thread — that re-render janks an open
// composer even though ComposeBox itself is memoized, so we pause it.
const pendingReloadRef = useRef(false)
const reloadDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastReloadAtRef = useRef(0)
const isSelectedRef = useRef<string | null>(null)
isSelectedRef.current = selectedThreadId
const composeOpenRef = useRef(false)
composeOpenRef.current = composeOpen
const isRefreshingRef = useRef(false)
isRefreshingRef.current = refreshing
const otherHasThreadsRef = useRef(false)
@ -1541,7 +1900,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
const doReload = useCallback(() => {
if (isRefreshingRef.current) return
if (isSelectedRef.current !== null) {
if (isSelectedRef.current !== null || composeOpenRef.current) {
pendingReloadRef.current = true
return
}
@ -1596,9 +1955,10 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
}
}, [triggerLiveReload])
// When user closes a thread, if updates arrived while they were reading, flush now.
// When the user closes the open thread or the compose-new modal, if updates
// arrived while it was open, flush them now.
useEffect(() => {
if (selectedThreadId !== null) return
if (selectedThreadId !== null || composeOpen) return
if (!pendingReloadRef.current) return
pendingReloadRef.current = false
lastReloadAtRef.current = Date.now()
@ -1606,7 +1966,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
if (otherHasThreadsRef.current) {
void reloadFirstPage('other', { silent: true })
}
}, [selectedThreadId, reloadFirstPage])
}, [selectedThreadId, composeOpen, reloadFirstPage])
// Manual refresh: wake the background sync loop. It updates inbox_lists/,
// the watcher fires, and triggerLiveReload picks up the changes. The
@ -1745,9 +2105,14 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
placeholder="Search loaded mail"
/>
</div>
<button type="button" className="gmail-icon-button" onClick={() => void refresh()} aria-label="Refresh">
{refreshing ? <LoaderIcon size={18} className="animate-spin" /> : <RefreshCw size={18} />}
</button>
<div className="gmail-topbar-actions">
<button type="button" className="gmail-icon-button" onClick={() => void refresh()} aria-label="Refresh">
{refreshing ? <LoaderIcon size={18} className="animate-spin" /> : <RefreshCw size={18} />}
</button>
<button type="button" className="gmail-icon-button" onClick={() => setComposeOpen(true)} aria-label="Compose new email">
<SquarePen size={18} />
</button>
</div>
</div>
{error && !hasAny ? (
@ -1814,6 +2179,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
</div>
)}
</div>
{composeOpen && <ComposeBox mode="new" onClose={closeCompose} />}
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} defaultTab="connections" />
</div>
)

View file

@ -1406,6 +1406,8 @@ export interface SendReplyOptions {
bodyText: string;
inReplyTo?: string;
references?: string;
/** Files to attach. contentBase64 is the raw (unwrapped) base64 of the file bytes. */
attachments?: Array<{ filename: string; mimeType: string; contentBase64: string }>;
}
export interface SendReplyResult {
@ -1427,6 +1429,44 @@ export async function getAccountEmail(): Promise<string | null> {
return getUserEmail(auth);
}
let cachedAccountName: string | null | undefined;
/**
* The connected account's display name, parsed from the `From` header of a
* recent SENT message (which is the user themselves). Cached for the process
* lifetime. Uses only the existing gmail.modify scope no profile/userinfo
* scope, so it never triggers a re-consent. Used by the composer to sign off
* AI-generated emails with the real name.
*/
export async function getAccountName(): Promise<string | null> {
if (cachedAccountName !== undefined) return cachedAccountName;
try {
const auth = await GoogleClientFactory.getClient();
if (!auth) return null;
const gmailClient = google.gmail({ version: 'v1', auth });
const list = await gmailClient.users.messages.list({ userId: 'me', labelIds: ['SENT'], maxResults: 1 });
const id = list.data.messages?.[0]?.id;
if (!id) {
cachedAccountName = null;
return null;
}
const msg = await gmailClient.users.messages.get({
userId: 'me',
id,
format: 'metadata',
metadataHeaders: ['From'],
});
const from = msg.data.payload?.headers?.find((h) => h.name?.toLowerCase() === 'from')?.value || '';
// Pull the display name out of `"Name" <email>` / `Name <email>`.
const name = from.match(/^\s*"?([^"<]+?)"?\s*</)?.[1]?.trim() || null;
cachedAccountName = name;
return name;
} catch (err) {
console.warn('[Gmail] getAccountName failed:', err);
return null;
}
}
export async function getConnectionStatus(): Promise<GmailConnectionStatus> {
const status = await GoogleClientFactory.getCredentialStatus(REQUIRED_SCOPE);
let email: string | null = null;
@ -1467,6 +1507,17 @@ function encodeMimeBase64(text: string): string {
?.join('\r\n') ?? '';
}
// Re-wrap an already-base64 string into 76-char lines (RFC 2045) and strip any
// whitespace the renderer may have included.
function wrapBase64(base64: string): string {
return base64.replace(/\s+/g, '').match(/.{1,76}/g)?.join('\r\n') ?? '';
}
// Quote a filename for a MIME header, dropping characters that would break it.
function sanitizeAttachmentName(name: string): string {
return (name || 'attachment').replace(/[\r\n"\\]/g, '_').trim() || 'attachment';
}
export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReplyResult> {
try {
const auth = await GoogleClientFactory.getClient();
@ -1486,7 +1537,10 @@ export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReply
: { bodyHtml: opts.bodyHtml.trim(), bodyText: opts.bodyText.trim() };
if (!replyBody.bodyText.trim()) return { error: 'Draft is empty.' };
const boundary = `b_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
const seed = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
const altBoundary = `alt_${seed}`;
const attachments = (opts.attachments ?? []).filter((a) => a.contentBase64);
const headers: string[] = [];
headers.push(`From: ${requireSafeHeaderValue('From', userEmail)}`);
headers.push(`To: ${safeTo}`);
@ -1496,24 +1550,52 @@ export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReply
if (safeInReplyTo) headers.push(`In-Reply-To: ${safeInReplyTo}`);
if (safeReferences) headers.push(`References: ${safeReferences}`);
headers.push('MIME-Version: 1.0');
headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
const parts: string[] = [];
parts.push(`--${boundary}`);
parts.push('Content-Type: text/plain; charset="UTF-8"');
parts.push('Content-Transfer-Encoding: base64');
parts.push('');
parts.push(encodeMimeBase64(replyBody.bodyText));
parts.push('');
parts.push(`--${boundary}`);
parts.push('Content-Type: text/html; charset="UTF-8"');
parts.push('Content-Transfer-Encoding: base64');
parts.push('');
parts.push(encodeMimeBase64(replyBody.bodyHtml));
parts.push('');
parts.push(`--${boundary}--`);
// The text+html body as a self-contained multipart/alternative block.
const altParts: string[] = [];
altParts.push(`--${altBoundary}`);
altParts.push('Content-Type: text/plain; charset="UTF-8"');
altParts.push('Content-Transfer-Encoding: base64');
altParts.push('');
altParts.push(encodeMimeBase64(replyBody.bodyText));
altParts.push('');
altParts.push(`--${altBoundary}`);
altParts.push('Content-Type: text/html; charset="UTF-8"');
altParts.push('Content-Transfer-Encoding: base64');
altParts.push('');
altParts.push(encodeMimeBase64(replyBody.bodyHtml));
altParts.push('');
altParts.push(`--${altBoundary}--`);
const message = `${headers.join('\r\n')}\r\n\r\n${parts.join('\r\n')}`;
let body: string;
if (attachments.length) {
// Wrap the alternative body plus each attachment in a multipart/mixed.
const mixedBoundary = `mixed_${seed}`;
headers.push(`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`);
const mixed: string[] = [];
mixed.push(`--${mixedBoundary}`);
mixed.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
mixed.push('');
mixed.push(altParts.join('\r\n'));
for (const att of attachments) {
const name = sanitizeAttachmentName(att.filename);
const mime = sanitizeAttachmentName(att.mimeType) || 'application/octet-stream';
mixed.push(`--${mixedBoundary}`);
mixed.push(`Content-Type: ${mime}; name="${name}"`);
mixed.push('Content-Transfer-Encoding: base64');
mixed.push(`Content-Disposition: attachment; filename="${name}"`);
mixed.push('');
mixed.push(wrapBase64(att.contentBase64));
mixed.push('');
}
mixed.push(`--${mixedBoundary}--`);
body = mixed.join('\r\n');
} else {
headers.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
body = altParts.join('\r\n');
}
const message = `${headers.join('\r\n')}\r\n\r\n${body}`;
const raw = Buffer.from(message, 'utf8')
.toString('base64')
.replace(/\+/g, '-')

View file

@ -9,6 +9,8 @@ import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { LlmModelConfig, LlmProvider } from "@x/shared/dist/models.js";
import z from "zod";
import { getGatewayProvider } from "./gateway.js";
import { getDefaultModelAndProvider, resolveProviderConfig } from "./defaults.js";
import { withUseCase } from "../analytics/use_case.js";
export const Provider = LlmProvider;
export const ModelConfig = LlmModelConfig;
@ -96,3 +98,47 @@ export async function testModelConnection(
clearTimeout(timeout);
}
}
export interface GenerateTextOptions {
prompt: string;
system?: string;
/** Model id. Falls back to the active default when omitted. */
model?: string;
/** Provider name (e.g. "rowboat", "openai"). Falls back to the active default. */
provider?: string;
}
export interface GenerateTextResult {
text?: string;
/** The model/provider actually used (after resolving defaults). */
model?: string;
provider?: string;
error?: string;
}
/**
* One-shot text generation for lightweight UI features (e.g. the email
* composer's "write with AI"). Resolves the requested model+provider, falling
* back to the active default, and returns the generated text. Never throws
* errors are returned in the result so the renderer can surface them.
*/
export async function generateOneShot(opts: GenerateTextOptions): Promise<GenerateTextResult> {
try {
const def = await getDefaultModelAndProvider();
const modelId = opts.model || def.model;
const providerName = opts.provider || def.provider;
const providerConfig = await resolveProviderConfig(providerName);
const languageModel = createProvider(providerConfig).languageModel(modelId);
const result = await withUseCase(
{ useCase: "copilot_chat", subUseCase: "email_compose" },
() => generateText({
model: languageModel,
...(opts.system ? { system: opts.system } : {}),
prompt: opts.prompt,
}),
);
return { text: result.text.trim(), model: modelId, provider: providerName };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}

View file

@ -190,6 +190,15 @@ const ipcSchemas = {
bodyText: z.string(),
inReplyTo: z.string().optional(),
references: z.string().optional(),
attachments: z
.array(
z.object({
filename: z.string(),
mimeType: z.string(),
contentBase64: z.string(),
}),
)
.optional(),
}),
res: z.object({
messageId: z.string().optional(),
@ -211,6 +220,12 @@ const ipcSchemas = {
email: z.string().nullable(),
}),
},
'gmail:getAccountName': {
req: z.object({}),
res: z.object({
name: z.string().nullable(),
}),
},
'gmail:archiveThread': {
req: z.object({ threadId: z.string().min(1) }),
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
@ -388,6 +403,27 @@ const ipcSchemas = {
error: z.string().optional(),
}),
},
'llm:getDefaultModel': {
req: z.null(),
res: z.object({
model: z.string(),
provider: z.string(),
}),
},
'llm:generate': {
req: z.object({
prompt: z.string().min(1),
system: z.string().optional(),
model: z.string().optional(),
provider: z.string().optional(),
}),
res: z.object({
text: z.string().optional(),
model: z.string().optional(),
provider: z.string().optional(),
error: z.string().optional(),
}),
},
'models:saveConfig': {
req: LlmModelConfig,
res: z.object({