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 && }
-
- Discard
-
+ {/* 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 && }
+
+ Discard
+
+
>
)
@@ -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"
- />
-