diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 94674d55..c471f990 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -69,8 +69,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, saveThreadDraft, deleteThreadDraft, listDraftThreads, searchThreads, archiveThread, trashThread, markThreadRead, markSectionRead, downloadAttachment, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js'; -import { getAutoReadEverythingElse, setAutoReadEverythingElse } from '@x/core/dist/config/gmail_sync_config.js'; +import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, saveThreadDraft, deleteThreadDraft, listDraftThreads, searchThreads, archiveThread, trashThread, markThreadRead, downloadAttachment, 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 { getGoogleDocsConnectionStatus, importGoogleDoc, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js'; @@ -771,19 +770,9 @@ export function setupIpcHandlers() { 'gmail:markThreadRead': async (_event, args) => { return markThreadRead(args.threadId, args.read); }, - 'gmail:markSectionRead': async (_event, args) => { - return markSectionRead(args.section, args.read); - }, 'gmail:downloadAttachment': async (_event, args) => { return downloadAttachment(args); }, - 'gmail:getAutoReadEverythingElse': async () => { - return { enabled: getAutoReadEverythingElse() }; - }, - 'gmail:setAutoReadEverythingElse': async (_event, args) => { - setAutoReadEverythingElse(args.enabled); - return {}; - }, 'gmail:saveMessageHeight': async (_event, args) => { saveMessageBodyHeight(args.threadId, args.messageId, args.height); return {}; diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index fbe6aac1..b2e7b4f1 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -11,7 +11,6 @@ import { useTheme } from '@/contexts/theme-context' import { SettingsDialog } from '@/components/settings-dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Switch } from '@/components/ui/switch' import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' type GmailThread = blocks.GmailThread @@ -198,6 +197,37 @@ function buildRecipients( return { to, cc } } +// Reply-chain headers (and thread placement) for the outgoing message. +// Replies rebuild the chain from the thread's messages. An edited draft is a +// single-message pseudo-thread whose own Message-ID must never be referenced +// (it dies on send, which would break recipients' threading) — reuse the +// In-Reply-To/References the draft already carries instead. Forwards and new +// messages start fresh. +function threadingHeaders( + mode: ComposeMode, + thread: GmailThread | undefined, +): { threadId?: string; inReplyTo?: string; references?: string } { + if (!thread || mode === 'forward' || mode === 'new') return {} + if (mode === 'draft') { + const draftMsg = latestMessage(thread) + return { + // Only a reply draft stays on its thread — a standalone draft's + // threadId is the phantom thread holding just the draft itself. + threadId: draftMsg?.inReplyToHeader ? thread.threadId : undefined, + inReplyTo: draftMsg?.inReplyToHeader, + references: draftMsg?.referencesHeader, + } + } + const messageIds = thread.messages + .map((m) => m.messageIdHeader) + .filter((v): v is string => Boolean(v)) + return { + threadId: thread.threadId, + inReplyTo: latestMessage(thread)?.messageIdHeader, + references: messageIds.join(' ') || undefined, + } +} + // Subject line for a reply ("Re: …") or forward ("Fwd: …"), avoiding double prefixes. function composeSubject(mode: ComposeMode, rawSubject?: string): string { const raw = (rawSubject || '').trim() @@ -408,6 +438,10 @@ function HtmlMessageBody({ message, threadId }: { message: GmailThreadMessage; t const [height, setHeight] = useState(message.bodyHeight ?? 80) const [hasQuote, setHasQuote] = useState(false) const [showQuotes, setShowQuotes] = useState(false) + // Read by handleLoad so a reload (theme switch rebuilds srcDoc) restores the + // expanded-quotes state on the fresh document. + const showQuotesRef = useRef(showQuotes) + useEffect(() => { showQuotesRef.current = showQuotes }, [showQuotes]) const adaptToTheme = useMemo(() => !isStyledHtml(message.bodyHtml!), [message.bodyHtml]) const srcDoc = useMemo( @@ -420,6 +454,16 @@ function HtmlMessageBody({ message, threadId }: { message: GmailThreadMessage; t const doc = iframe?.contentDocument if (!doc?.body) return setHasQuote(!!doc.querySelector('.gmail_quote, .gmail_attr, blockquote[type="cite"]')) + if (showQuotesRef.current) doc.documentElement.dataset.showQuotes = 'true' + // Clicking into the email body focuses the iframe document, which would + // otherwise swallow every list/thread shortcut (the parent's document + // keydown listeners never fire). The sandbox has allow-same-origin but no + // allow-scripts, so we forward from out here; the listener dies with the + // document on the next load. + doc.addEventListener('keydown', (event) => { + const clone = new KeyboardEvent('keydown', event) + if (!document.dispatchEvent(clone)) event.preventDefault() + }) const measure = () => { // Measure off body only. documentElement.scrollHeight stretches to fill // the iframe viewport, so once we size the iframe up (e.g. user expanded @@ -598,8 +642,12 @@ function ComposeToolbarButton({ } function ComposeToolbar({ editor, onOpenLink }: { editor: Editor; onOpenLink: () => void }) { + // Content-sized (no flex-1: a 0 basis always "fits" the current flex line, + // so the toolbar would never wrap — it would get squeezed until its + // fixed-size buttons overflowed the neighbors). The inner flex-wrap stacks + // the buttons if even a full line is too narrow to hold them. return ( -
+
)} -
-
+ {/* flex-wrap: every button here is shrink-0, so on a narrow pane the + formatting toolbar wraps to its own line instead of overflowing + into (and clipping) the Discard button. */} +
+
- {editor && } - + {/* Toolbar + Discard share one wrap unit so Discard never strands on + a line of its own: wide panes show a single row (unit grows, so + ml-auto pins Discard to the right edge); narrow panes wrap the + whole unit to a second full-width row with the same alignment. + flex-auto (content basis) is what makes the unit wrap at all — + a flex-1 zero basis would "fit" forever and squeeze instead. */} +
+ {editor && } + +
) @@ -2329,8 +2401,6 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = const [searching, setSearching] = useState(false) const [searchError, setSearchError] = useState(null) const searchEpoch = useRef(0) - // Persistent preference: auto-mark future "Everything else" mail as read. - const [autoReadOther, setAutoReadOther] = useState(false) // Gmail sync uses the native Google OAuth connection. const [emailConnection, setEmailConnection] = useState(null) const [settingsOpen, setSettingsOpen] = useState(false) @@ -2407,16 +2477,6 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = return () => window.clearTimeout(handle) }, [query]) - // Load the persisted "auto-read Everything else" preference on mount. - useEffect(() => { - let cancelled = false - void window.ipc - .invoke('gmail:getAutoReadEverythingElse', {}) - .then((res) => { if (!cancelled) setAutoReadOther(res.enabled) }) - .catch(() => {}) - return () => { cancelled = true } - }, []) - const deleteDraftAction = useCallback(async (thread: GmailThread) => { const id = thread.draftId if (!id) return @@ -2509,38 +2569,6 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = } }, [updateThreadInState]) - // Toggle the read state of a whole category — server-side across the entire - // category, not just the rows currently loaded. Optimistically flips the UI. - const markSectionReadAction = useCallback(async (section: InboxSection, read: boolean) => { - setSection(section, (prev) => ({ - ...prev, - threads: prev.threads.map((t) => ({ - ...t, - unread: !read, - messages: t.messages.map((m) => ({ ...m, unread: !read })), - })), - })) - try { - const result = await window.ipc.invoke('gmail:markSectionRead', { section, read }) - if (!result.ok && result.error) toast(`Could not update read state: ${result.error}`, 'error') - } catch (err) { - toast(`Could not update read state: ${err instanceof Error ? err.message : String(err)}`, 'error') - } - }, [setSection]) - - // "Mark as read" toggle for Everything else. Turning it ON persists the - // preference AND marks every current "other" thread read; turning it OFF only - // clears the preference (future mail), leaving existing threads untouched. - const setAutoReadOtherAction = useCallback(async (enabled: boolean) => { - setAutoReadOther(enabled) - try { - await window.ipc.invoke('gmail:setAutoReadEverythingElse', { enabled }) - } catch (err) { - toast(`Could not update preference: ${err instanceof Error ? err.message : String(err)}`, 'error') - } - if (enabled) await markSectionReadAction('other', true) - }, [markSectionReadAction]) - const archiveThreadAction = useCallback(async (threadId: string) => { // Start the slide-out right away; the row is removed once both the IPC // and the animation have finished. A failure clears the flag → snap back. @@ -3284,19 +3312,9 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
Everything else -
-
- Mark as read - void setAutoReadOtherAction(checked)} - aria-label="Mark Everything else as read now and going forward" - /> -
- - {other.threads.length}{other.hasReachedEnd ? '' : '+'} thread{other.threads.length === 1 ? '' : 's'} - -
+ + {other.threads.length}{other.hasReachedEnd ? '' : '+'} thread{other.threads.length === 1 ? '' : 's'} +
{visibleOther.map(renderRow)} {!other.hasReachedEnd && ( diff --git a/apps/x/packages/core/src/config/gmail_sync_config.ts b/apps/x/packages/core/src/config/gmail_sync_config.ts index 9e8cdec1..d59be6cf 100644 --- a/apps/x/packages/core/src/config/gmail_sync_config.ts +++ b/apps/x/packages/core/src/config/gmail_sync_config.ts @@ -20,13 +20,6 @@ const MAX_MAX_EMAILS = 5000; interface GmailSyncConfig { maxEmails: number; - /** - * When true, threads the classifier labels as "Everything else" are - * automatically marked read as they arrive during sync. Toggling this on - * only governs FUTURE mail; existing threads are marked read separately at - * toggle time (and are never touched when this is turned off). - */ - autoReadEverythingElse?: boolean; } function clampMaxEmails(value: number): number { @@ -74,15 +67,3 @@ export function setMaxEmails(maxEmails: number): void { writeConfig({ ...readConfig(), maxEmails: clampMaxEmails(maxEmails) }); } -/** - * Whether newly-arriving "Everything else" threads should be auto-marked read - * during sync. Defaults to false (off) when unset. - */ -export function getAutoReadEverythingElse(): boolean { - return readConfig()?.autoReadEverythingElse === true; -} - -/** Persist the "auto-mark Everything else as read" preference. */ -export function setAutoReadEverythingElse(enabled: boolean): void { - writeConfig({ ...readConfig(), autoReadEverythingElse: enabled }); -} diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index 80945c5c..83011e50 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -4,7 +4,7 @@ import { google, gmail_v1 as gmail } from 'googleapis'; import { NodeHtmlMarkdown } from 'node-html-markdown' import { OAuth2Client } from 'google-auth-library'; import { WorkDir } from '../config/config.js'; -import { getMaxEmails, getAutoReadEverythingElse } from '../config/gmail_sync_config.js'; +import { getMaxEmails } from '../config/gmail_sync_config.js'; import { GoogleClientFactory } from './google-client-factory.js'; import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js'; import { limitEventItems } from './limit_event_items.js'; @@ -191,81 +191,6 @@ export async function markThreadRead(threadId: string, read: boolean = true): Pr } } -export interface MarkSectionReadResult { - ok: boolean; - count: number; - error?: string; -} - -/** - * Toggle the read state of every thread in a category ('important' or 'other') - * on the server. The category is our local AI classification, so the set is read - * from the inbox cache; the change is applied on Gmail via a chunked - * messages.batchModify (UNREAD removed when read=true, added when read=false), - * then mirrored into the local cache. Only threads that actually need the change - * are touched. - */ -export async function markSectionRead( - section: 'important' | 'other', - read: boolean = true, -): Promise { - try { - const gmailClient = await getGmailClientOrThrow(); - - // Gather threads in this category that still need the change. - const targets: { file: string; entry: SnapshotCacheEntry; messageIds: string[] }[] = []; - if (fs.existsSync(CACHE_DIR)) { - let names: string[] = []; - try { names = fs.readdirSync(CACHE_DIR); } catch { names = []; } - for (const name of names) { - if (!name.endsWith('.json')) continue; - const file = path.join(CACHE_DIR, name); - let entry: SnapshotCacheEntry; - try { entry = JSON.parse(fs.readFileSync(file, 'utf-8')) as SnapshotCacheEntry; } catch { continue; } - const snap = entry?.snapshot; - if (!snap) continue; - const isUnread = snap.unread === true; - // Marking read: skip already-read. Marking unread: skip already-unread. - if (read ? !isUnread : isUnread) continue; - // Mirror snapshotImportance: missing importance counts as 'important'. - const importance = snap.importance === 'other' ? 'other' : 'important'; - if (importance !== section) continue; - const messageIds = snap.messages.map((m) => m.id).filter((id): id is string => Boolean(id)); - targets.push({ file, entry, messageIds }); - } - } - - if (targets.length === 0) return { ok: true, count: 0 }; - - // Apply on Gmail (batchModify caps at 1000 message ids per call). - const allMessageIds = targets.flatMap((t) => t.messageIds); - const labelChange = read ? { removeLabelIds: ['UNREAD'] } : { addLabelIds: ['UNREAD'] }; - for (let i = 0; i < allMessageIds.length; i += 1000) { - const ids = allMessageIds.slice(i, i + 1000); - if (ids.length === 0) continue; - await gmailClient.users.messages.batchModify({ - userId: 'me', - requestBody: { ids, ...labelChange }, - }); - } - - // Mirror into the local cache so the UI updates without a full resync. - for (const t of targets) { - for (const m of t.entry.snapshot.messages) m.unread = !read; - t.entry.snapshot.unread = !read; - try { - fs.writeFileSync(t.file, JSON.stringify(t.entry), 'utf-8'); - } catch (err) { - console.warn(`[Gmail cache] markSectionRead write failed:`, err); - } - } - - return { ok: true, count: targets.length }; - } catch (err) { - return { ok: false, count: 0, error: err instanceof Error ? err.message : String(err) }; - } -} - interface SyncedThread { threadId: string; markdown: string; @@ -308,6 +233,14 @@ export interface GmailThreadSnapshot { }>; messageIdHeader?: string; isDraft?: boolean; + /** + * The draft's own stored In-Reply-To / References headers. Only set + * on draft messages (see buildDraftSnapshot) — the composer reuses + * them on send since the Drafts pseudo-thread has no other messages + * to rebuild the reply chain from. + */ + inReplyToHeader?: string; + referencesHeader?: string; }>; } @@ -929,23 +862,6 @@ async function buildAndCacheSnapshot( console.warn(`[Gmail] classify failed for ${threadId}:`, err); } - // Auto-mark newly-classified "Everything else" mail as read when the user - // has opted in. Governs future mail only — existing threads are handled at - // toggle time via markSectionRead. - if (snapshot.importance === 'other' && snapshot.unread && getAutoReadEverythingElse()) { - try { - await gmailClient.users.threads.modify({ - userId: 'me', - id: threadId, - requestBody: { removeLabelIds: ['UNREAD'] }, - }); - for (const m of snapshot.messages) m.unread = false; - snapshot.unread = false; - } catch (err) { - console.warn(`[Gmail] auto-read (Everything else) failed for ${threadId}:`, err); - } - } - if (threadData.historyId) { writeCachedSnapshot(threadId, threadData.historyId, snapshot); } @@ -1562,6 +1478,14 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir: for (const item of record.messagesAdded) { const labels = item.message?.labelIds ?? []; if (labels.includes('SPAM') || labels.includes('TRASH')) continue; + // Drafts are not incoming mail: every composer autosave + // (ours or another Gmail client's) adds a DRAFT message. + // Processing it would leak unsent draft bodies into + // gmail_sync/ markdown + knowledge events, fire "New + // email" notifications, and re-run the LLM classifier per + // autosave. The Drafts view reads live via gmail:getDrafts + // instead. + if (labels.includes('DRAFT')) continue; if (item.message?.threadId) { threadIds.add(item.message.threadId); } @@ -2023,16 +1947,37 @@ export async function saveThreadDraft(opts: SaveDraftOptions): Promise; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index df548aeb..ba9ad7a1 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -288,10 +288,6 @@ const ipcSchemas = { req: z.object({ threadId: z.string().min(1), read: z.boolean().optional() }), res: z.object({ ok: z.boolean(), error: z.string().optional() }), }, - 'gmail:markSectionRead': { - req: z.object({ section: z.enum(['important', 'other']), read: z.boolean() }), - res: z.object({ ok: z.boolean(), count: z.number().int().nonnegative(), error: z.string().optional() }), - }, 'gmail:downloadAttachment': { req: z.object({ messageId: z.string().min(1), @@ -300,14 +296,6 @@ const ipcSchemas = { }), res: z.object({ ok: z.boolean(), error: z.string().optional() }), }, - 'gmail:getAutoReadEverythingElse': { - req: z.object({}), - res: z.object({ enabled: z.boolean() }), - }, - 'gmail:setAutoReadEverythingElse': { - req: z.object({ enabled: z.boolean() }), - res: z.object({}), - }, 'gmail:saveMessageHeight': { req: z.object({ threadId: z.string().min(1),