diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 88c0fdc1..d424f857 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -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('modelConfigRepo'); await repo.setConfig(args); diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 02cfd7bd..09cf87bf 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -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; diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index 7d5c5f1d..b8b2e537 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -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, ''') } +// Convert AI-generated plain text into the simple paragraph HTML the Tiptap +// editor expects (blank lines → paragraphs, single newlines →
). +function plainTextToHtml(text: string): string { + return text + .split(/\n{2,}/) + .map((para) => `

${escapeHtml(para.trim()).replace(/\n/g, '
')}

`) + .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 void }) { return (
+ + + 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: \n' + + '\n' + + '\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 ,"). ' + + '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: \n' + + '\n' + + '\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" 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([]) const [showCc, setShowCc] = useState(initialRecipients.cc.length > 0) const [showBcc, setShowBcc] = useState(false) - const [subject, setSubject] = useState(() => composeSubject(mode, thread.subject)) - const modeLabel = mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply all' : 'Reply' + const [subject, setSubject] = useState(() => (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('') + 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(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((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 ( -
-
+ const card = ( +
event.stopPropagation() : undefined} + > +
{modeLabel} - +
{!showCc && } @@ -1059,18 +1323,83 @@ function ComposeBox({ /> {showCc && } {showBcc && } - {mode === 'forward' && ( + {isNew && ( + <> +
+ 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() + } + }} + /> + +
+
+ + {TONE_PRESETS.map((preset) => ( + + ))} +
+ + )} + {(isNew || mode === 'forward') && (
Subject setSubject(event.target.value)} - placeholder="Subject" />
)} + { + void addFiles(event.target.value ? event.currentTarget.files : null) + event.currentTarget.value = '' + }} + /> + {attachments.length > 0 && ( +
+ {attachments.map((att) => ( +
+ + {att.filename} + {formatAttachmentSize(att.size)} + +
+ ))} +
+ )} {linkOpen && (
event.preventDefault()}> { void sendInGmail() }} disabled={sending} - title="Send this reply via Gmail" + title={isNew ? 'Send this email via Gmail' : 'Send this reply via Gmail'} > {sending ? : } {sending ? 'Sending…' : 'Send'} @@ -1107,19 +1436,40 @@ function ComposeBox({ + {thread && ( + + )}
{editor && }
) -} + + if (isNew) { + return ( +
+ {card} +
+ ) + } + 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(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(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 | null>(null) const lastReloadAtRef = useRef(0) const isSelectedRef = useRef(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" />
- +
+ + +
{error && !hasAny ? ( @@ -1814,6 +2179,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = )} + {composeOpen && } ) diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index ce2a17be..52f8ca6f 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -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 { 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 { + 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" ` / `Name `. + const name = from.match(/^\s*"?([^"<]+?)"?\s* { 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 { try { const auth = await GoogleClientFactory.getClient(); @@ -1486,7 +1537,10 @@ export async function sendThreadReply(opts: SendReplyOptions): Promise 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 { + 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) }; + } +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 6c18de40..e504d1cb 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -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({