From 3a27c2ebd632a57762d657f0c13189f8f901d549 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Sat, 23 May 2026 08:51:08 +0530 Subject: [PATCH] added replyall, cc, bcc etc --- apps/x/apps/main/src/ipc.ts | 5 +- apps/x/apps/renderer/src/App.css | 120 ++++++++ .../renderer/src/components/email-view.tsx | 258 ++++++++++++++++-- .../packages/core/src/knowledge/sync_gmail.ts | 11 + apps/x/packages/shared/src/ipc.ts | 8 + 5 files changed, 375 insertions(+), 27 deletions(-) diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index eea4ad61..e1497e16 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -47,7 +47,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 } from '@x/core/dist/knowledge/sync_gmail.js'; +import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail } from '@x/core/dist/knowledge/sync_gmail.js'; import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js'; import { getInstallationId } from '@x/core/dist/analytics/installation.js'; import { API_URL } from '@x/core/dist/config/env.js'; @@ -496,6 +496,9 @@ export function setupIpcHandlers() { 'gmail:sendReply': async (_event, args) => { return sendThreadReply(args); }, + 'gmail:getAccountEmail': async () => { + return { email: await getAccountEmail() }; + }, 'gmail:archiveThread': async (_event, args) => { return archiveThread(args.threadId); }, diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index b7c57b0e..28f9539c 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -743,6 +743,126 @@ font: inherit; } +.gmail-compose-label { + flex: none; + min-width: 28px; + color: var(--gm-text-muted); +} + +.gmail-compose-subject-input { + min-width: 0; + flex: 1; + border: none; + outline: none; + background: transparent; + color: var(--gm-text); + font: inherit; +} + +/* Recipient (To / Cc / Bcc) rows with editable chips */ +.gmail-recipient-row { + display: flex; + align-items: flex-start; + gap: 8px; + min-height: 34px; + padding: 5px 12px; + border-bottom: 1px solid var(--gm-border); + font-size: 13px; +} + +.gmail-recipient-label { + flex: none; + min-width: 28px; + padding-top: 5px; + color: var(--gm-text-muted); +} + +.gmail-recipient-field { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + flex: 1; + min-width: 0; +} + +.gmail-recipient-chip { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 100%; + height: 24px; + padding: 0 4px 0 10px; + border-radius: 12px; + background: var(--gm-bg-pill); + color: var(--gm-text); + font-size: 12px; + line-height: 1; +} + +.gmail-recipient-chip-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 240px; +} + +.gmail-recipient-chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border: none; + border-radius: 50%; + background: transparent; + color: var(--gm-text-muted); + font-size: 14px; + line-height: 1; + cursor: pointer; +} + +.gmail-recipient-chip-remove:hover { + background: var(--gm-bg-pill-hover); + color: var(--gm-text); +} + +.gmail-recipient-input { + flex: 1 1 80px; + min-width: 80px; + height: 24px; + border: none; + outline: none; + background: transparent; + color: var(--gm-text); + font: inherit; + font-size: 13px; +} + +.gmail-recipient-trailing { + flex: none; + padding-top: 5px; +} + +.gmail-recipient-toggles { + display: flex; + gap: 10px; +} + +.gmail-recipient-toggles button { + border: none; + background: transparent; + color: var(--gm-text-muted); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.gmail-recipient-toggles button:hover { + color: var(--gm-text); + text-decoration: underline; +} + .gmail-compose-toolbar { 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 4e42eea5..1848b2f2 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, Search, Send, Sparkles, Strikethrough, Trash2 } from 'lucide-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 { useEditor, EditorContent, type Editor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' @@ -80,6 +80,88 @@ function latestMessage(thread: GmailThread): GmailThreadMessage | undefined { return thread.messages[thread.messages.length - 1] } +// Split a raw header recipient string (e.g. `"Jo Bloggs" , b@y.com`) into +// individual address tokens, respecting commas inside quotes/angle brackets. +function splitAddresses(raw?: string): string[] { + if (!raw) return [] + const tokens: string[] = [] + let buf = '' + let inQuote = false + let depth = 0 + for (const ch of raw) { + if (ch === '"') inQuote = !inQuote + else if (ch === '<') depth += 1 + else if (ch === '>') depth = Math.max(0, depth - 1) + if ((ch === ',' || ch === ';' || ch === '\n') && !inQuote && depth === 0) { + const token = buf.trim() + if (token) tokens.push(token) + buf = '' + continue + } + buf += ch + } + const last = buf.trim() + if (last) tokens.push(last) + return tokens +} + +// Display label for a recipient chip: the display name if present, else the bare address. +function recipientLabel(token: string): string { + const named = token.match(/^\s*"?([^"<]+?)"?\s*<[^>]+>\s*$/) + if (named?.[1]?.trim()) return named[1].trim() + return extractAddress(token) +} + +// Dedupe tokens by lowercased email address, dropping any whose address is in `exclude`. +function dedupeRecipients(tokens: string[], exclude: Set): string[] { + const seen = new Set(exclude) + const out: string[] = [] + for (const token of tokens) { + const addr = extractAddress(token).toLowerCase() + if (!addr || seen.has(addr)) continue + seen.add(addr) + out.push(token) + } + return out +} + +// Compute the To / Cc recipients for a reply, reply-all, or forward, excluding "me". +function buildRecipients( + mode: ComposeMode, + thread: GmailThread, + selfEmail: string, +): { to: string[]; cc: string[] } { + if (mode === 'forward') return { to: [], cc: [] } + + const latest = latestMessage(thread) + const self = selfEmail.toLowerCase() + const fromAddr = latest?.from ? extractAddress(latest.from).toLowerCase() : '' + const iAmSender = Boolean(self) && fromAddr === self + + // If my own message is the latest, reply to whoever I sent it to; otherwise reply to the sender. + const rawTo = iAmSender ? splitAddresses(latest?.to) : (latest?.from ? [latest.from] : []) + const ccPool = iAmSender + ? splitAddresses(latest?.cc) + : [...splitAddresses(latest?.to), ...splitAddresses(latest?.cc)] + + const selfSet = new Set(self ? [self] : []) + const to = dedupeRecipients(rawTo, selfSet) + + if (mode === 'reply') return { to, cc: [] } + + const ccExclude = new Set(selfSet) + for (const token of to) ccExclude.add(extractAddress(token).toLowerCase()) + const cc = dedupeRecipients(ccPool, ccExclude) + return { to, cc } +} + +// Subject line for a reply ("Re: …") or forward ("Fwd: …"), avoiding double prefixes. +function composeSubject(mode: ComposeMode, rawSubject?: string): string { + const raw = (rawSubject || '').trim() + if (mode === 'forward') return /^fwd:/i.test(raw) ? raw : `Fwd: ${raw}`.trim() + return /^re:/i.test(raw) ? raw : `Re: ${raw}`.trim() +} + const PREFETCH_HOVER_MS = 180 const PREFETCH_MAX_IMAGES_PER_THREAD = 12 @@ -374,7 +456,7 @@ function MessageAttachments({ attachments }: { attachments: NonNullable void + autoFocus?: boolean + trailing?: React.ReactNode +}) { + const [draft, setDraft] = useState('') + const inputRef = useRef(null) + + useEffect(() => { + if (autoFocus) inputRef.current?.focus() + }, [autoFocus]) + + const commit = (raw: string) => { + const additions = splitAddresses(raw) + if (additions.length === 0) return + onChange(dedupeRecipients([...value, ...additions], new Set())) + setDraft('') + } + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ',' || event.key === ';' || (event.key === 'Tab' && draft.trim())) { + if (draft.trim()) { + event.preventDefault() + commit(draft) + } + } else if (event.key === 'Backspace' && !draft && value.length > 0) { + onChange(value.slice(0, -1)) + } + } + + return ( +
+ {label} +
+ {value.map((token, index) => ( + + {recipientLabel(token)} + + + ))} + setDraft(event.target.value)} + onKeyDown={onKeyDown} + onBlur={() => { if (draft.trim()) commit(draft) }} + onPaste={(event) => { + const text = event.clipboardData.getData('text') + if (text && /[,;\n]/.test(text)) { + event.preventDefault() + commit(text) + } + }} + /> +
+ {trailing &&
{trailing}
} +
+ ) +} + function ComposeBox({ mode, thread, + selfEmail, onClose, }: { mode: ComposeMode thread: GmailThread + selfEmail: string onClose: () => void }) { const latest = latestMessage(thread) - const to = mode === 'reply' ? extractAddress(latest?.from) : '' + const initialRecipients = useMemo( + () => buildRecipients(mode, thread, selfEmail), + [mode, thread, selfEmail], + ) + + const [toList, setToList] = useState(initialRecipients.to) + const [ccList, setCcList] = useState(initialRecipients.cc) + 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 initialContent = useMemo(() => { - if (mode !== 'reply') return '' + if (mode === 'forward') return '' // Gmail-side draft (user's own work) wins over the AI-generated draft. const source = thread.gmail_draft || thread.draft_response if (!source) return '' @@ -503,7 +675,7 @@ function ComposeBox({ StarterKit, Link.configure({ openOnClick: false, autolink: true }), Placeholder.configure({ - placeholder: mode === 'reply' ? 'Write your reply…' : 'Write a message…', + placeholder: mode === 'forward' ? 'Write a message…' : 'Write your reply…', }), ], editorProps: { @@ -565,17 +737,11 @@ function ComposeBox({ return } - const recipient = mode === 'reply' ? extractAddress(latest?.from) : '' - if (!recipient) { - toast('No recipient found for this thread.', 'error') + if (toList.length === 0) { + toast('Add at least one recipient.', 'error') return } - const rawSubject = thread.subject || '' - const subject = mode === 'reply' - ? (/^re:/i.test(rawSubject) ? rawSubject : `Re: ${rawSubject}`.trim()) - : (/^fwd:/i.test(rawSubject) ? rawSubject : `Fwd: ${rawSubject}`.trim()) - // Build References chain from all known message ids (newest last). const messageIds = thread.messages .map((m) => m.messageIdHeader) @@ -587,8 +753,10 @@ function ComposeBox({ try { const result = await window.ipc.invoke('gmail:sendReply', { threadId: thread.threadId, - to: recipient, - subject, + to: toList.join(', '), + cc: ccList.length ? ccList.join(', ') : undefined, + bcc: bccList.length ? bccList.join(', ') : undefined, + subject: subject.trim() || composeSubject(mode, thread.subject), bodyHtml: html, bodyText: text, inReplyTo, @@ -610,13 +778,13 @@ function ComposeBox({ const refineWithCopilot = () => { if (!editor) return const currentDraft = editor.getText().trim() - const subject = thread.subject || '(No subject)' + const threadSubject = thread.subject || '(No subject)' const lines: string[] = [] lines.push(`Help me refine this draft email response. **Please ask me how I want to refine it before making any changes** — wait for my answer, then apply the edits.`) lines.push('') - lines.push(`**Mode:** ${mode === 'reply' ? 'Reply' : 'Forward'}`) - lines.push(`**Subject:** ${subject}`) + lines.push(`**Mode:** ${modeLabel}`) + lines.push(`**Subject:** ${threadSubject}`) lines.push('') lines.push(`## Thread (${thread.messages.length} message${thread.messages.length === 1 ? '' : 's'})`) lines.push('') @@ -641,17 +809,32 @@ function ComposeBox({ return (
- {mode === 'reply' ? 'Reply' : 'Forward'} - -
-
- {mode === 'reply' ? 'To' : 'Recipients'} - + {modeLabel} +
+ + {!showCc && } + {!showBcc && } +
+ } + /> + {showCc && } + {showBcc && } {mode === 'forward' && (
- Subject - + Subject + setSubject(event.target.value)} + placeholder="Subject" + />
)} @@ -715,10 +898,25 @@ function ThreadDetail({ hidden?: boolean }) { const [composeMode, setComposeMode] = useState(null) + const [selfEmail, setSelfEmail] = useState('') const [expandedIndices, setExpandedIndices] = useState>( () => new Set(thread.messages.length > 0 ? [thread.messages.length - 1] : []) ) + // The connected Gmail address, so reply-all can exclude "me". + useEffect(() => { + let cancelled = false + window.ipc.invoke('gmail:getAccountEmail', {}) + .then((res) => { if (!cancelled && res?.email) setSelfEmail(res.email) }) + .catch(() => {}) + return () => { cancelled = true } + }, []) + + const canReplyAll = useMemo(() => { + const { to, cc } = buildRecipients('replyAll', thread, selfEmail) + return cc.length > 0 || to.length > 1 + }, [thread, selfEmail]) + const toggleExpand = useCallback((index: number) => { setExpandedIndices((prev) => { const next = new Set(prev) @@ -788,6 +986,12 @@ function ThreadDetail({ Reply + {canReplyAll && ( + + )}