feat(email): on-demand attachment download, search clear, per-category read prefs

- Download search-result attachments on demand before opening (fixes Linux
  where xdg-open reports success on a missing file so the old open-then-
  download fallback never fired)
- Add a clear button to the search box that dismisses search and returns to
  the inbox
- Remove the read toggle from the Important section
- Replace the Everything else "Read" toggle with a "Mark as read" preference:
  turning it on marks all current and future "Everything else" mail read;
  turning it off only stops auto-reading future mail, leaving current threads
  untouched
This commit is contained in:
hrsvrn 2026-07-01 23:51:08 +05:30
parent ab9bce6203
commit 063f6892a8
7 changed files with 269 additions and 50 deletions

View file

@ -69,7 +69,8 @@ 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, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.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 { 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';
@ -773,6 +774,16 @@ export function setupIpcHandlers() {
'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 {};

View file

@ -169,6 +169,25 @@
color: var(--gm-placeholder);
}
.gmail-search-clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--gm-text-muted);
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.gmail-search-clear:hover {
background: var(--gm-icon-hover-bg);
color: var(--gm-text);
}
.gmail-icon-button {
display: inline-flex;
align-items: center;

View file

@ -499,16 +499,32 @@ function formatAttachmentSize(bytes?: number): string {
}
function MessageAttachments({ attachments }: { attachments: NonNullable<GmailThreadMessage['attachments']> }) {
const openAttachment = (path: string, filename: string) => {
void window.ipc
.invoke('shell:openPath', { path })
.then((result) => {
if (result?.error) toast(`Could not open ${filename}: ${result.error}`, 'error')
})
.catch((err) => {
const message = err instanceof Error ? err.message : String(err)
toast(`Could not open ${filename}: ${message}`, 'error')
})
const openAttachment = async (att: NonNullable<GmailThreadMessage['attachments']>[number]) => {
try {
// Ensure the file is on disk before handing off to the OS opener. Inbox
// attachments are saved during sync, but search-result attachments are
// only fetched on demand. gmail:downloadAttachment short-circuits when the
// file already exists, so calling it first is cheap and guarantees the
// file is present — we can't rely on shell:openPath reporting a missing
// file as an error (xdg-open on Linux reports success even when the path
// doesn't exist, so the old open-then-download fallback never fired).
if (att.messageId) {
const dl = await window.ipc.invoke('gmail:downloadAttachment', {
messageId: att.messageId,
savedPath: att.savedPath,
attachmentId: att.attachmentId,
})
if (!dl.ok) {
toast(`Could not download ${att.filename}: ${dl.error ?? 'unknown error'}`, 'error')
return
}
}
const result = await window.ipc.invoke('shell:openPath', { path: att.savedPath })
if (result?.error) toast(`Could not open ${att.filename}: ${result.error}`, 'error')
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast(`Could not open ${att.filename}: ${message}`, 'error')
}
}
return (
@ -520,7 +536,7 @@ function MessageAttachments({ attachments }: { attachments: NonNullable<GmailThr
key={att.savedPath}
type="button"
className="gmail-attachment"
onClick={() => openAttachment(att.savedPath, att.filename)}
onClick={() => void openAttachment(att)}
title={`Open ${att.filename}`}
>
<Paperclip size={13} />
@ -1975,6 +1991,8 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
const [searching, setSearching] = useState(false)
const [searchError, setSearchError] = useState<string | null>(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<GmailConnectionStatus | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
@ -2026,6 +2044,16 @@ 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
@ -2155,6 +2183,19 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
}
}, [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) => {
try {
const result = await window.ipc.invoke('gmail:archiveThread', { threadId })
@ -2447,8 +2488,6 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
const visibleImportant = useMemo(() => filterThreads(important.threads), [important.threads, filterThreads])
const visibleOther = useMemo(() => filterThreads(other.threads), [other.threads, filterThreads])
const visibleDrafts = useMemo(() => filterThreads(drafts), [drafts, filterThreads])
const importantHasUnread = important.threads.some((t) => t.unread)
const otherHasUnread = other.threads.some((t) => t.unread)
const hasAny = important.threads.length > 0 || other.threads.length > 0
const initialLoading = !hasAny && refreshing
@ -2570,6 +2609,17 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
onChange={(event) => setQuery(event.target.value)}
placeholder="Search all mail"
/>
{query && (
<button
type="button"
className="gmail-search-clear"
onClick={() => setQuery('')}
title="Clear search"
aria-label="Clear search"
>
<X size={16} />
</button>
)}
</div>
<div className="gmail-topbar-actions">
<div className="flex items-center rounded-md border border-border p-0.5 text-xs font-medium">
@ -2642,19 +2692,9 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
<section className="gmail-section">
<div className="gmail-list-header">
<span>Important</span>
<div className="flex items-center gap-2.5">
<div className="flex items-center gap-1.5" title={importantHasUnread ? 'Mark all in Important as read' : 'Mark all in Important as unread'}>
<span className="text-xs text-muted-foreground">Read</span>
<Switch
checked={!importantHasUnread}
onCheckedChange={(checked) => void markSectionReadAction('important', checked)}
aria-label="Mark all of Important read or unread"
/>
</div>
<span>
{important.threads.length}{important.hasReachedEnd ? '' : '+'} thread{important.threads.length === 1 ? '' : 's'}
</span>
</div>
<span>
{important.threads.length}{important.hasReachedEnd ? '' : '+'} thread{important.threads.length === 1 ? '' : 's'}
</span>
</div>
{visibleImportant.map(renderRow)}
{!important.hasReachedEnd && (
@ -2671,12 +2711,12 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
<div className="gmail-list-header">
<span>Everything else</span>
<div className="flex items-center gap-2.5">
<div className="flex items-center gap-1.5" title={otherHasUnread ? 'Mark all in Everything else as read' : 'Mark all in Everything else as unread'}>
<span className="text-xs text-muted-foreground">Read</span>
<div className="flex items-center gap-1.5" title="Mark Everything else as read — applies to these and future emails">
<span className="text-xs text-muted-foreground">Mark as read</span>
<Switch
checked={!otherHasUnread}
onCheckedChange={(checked) => void markSectionReadAction('other', checked)}
aria-label="Mark all of Everything else read or unread"
checked={autoReadOther}
onCheckedChange={(checked) => void setAutoReadOtherAction(checked)}
aria-label="Mark Everything else as read now and going forward"
/>
</div>
<span>