learns email importance from preference actions

This commit is contained in:
Arjun 2026-07-05 02:36:01 +05:30
parent 258f157b39
commit 9d31d25046
9 changed files with 381 additions and 15 deletions

View file

@ -76,7 +76,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, downloadAttachment, 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, downloadAttachment, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus, setThreadImportance } 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';
@ -819,6 +819,10 @@ export function setupIpcHandlers() {
'gmail:getAccountName': async () => {
return { name: await getAccountName() };
},
'gmail:setImportance': async (_event, args) => {
const result = setThreadImportance(args.threadId, args.importance);
return { ok: result.success, previous: result.previous, error: result.error };
},
'gmail:archiveThread': async (_event, args) => {
return archiveThread(args.threadId);
},

View file

@ -330,6 +330,11 @@
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
/* The overlay may extend over the subject text; a backdrop matching the
row's current background (see --gm-row-actions-bg below) plus a soft left
fade keeps it readable instead of colliding. */
padding-left: 18px;
background: linear-gradient(to right, transparent, var(--gm-row-actions-bg, var(--gm-bg-row-hover)) 18px);
}
.gmail-row-shell:hover .gmail-row-actions {
@ -337,6 +342,19 @@
pointer-events: auto;
}
/* Backdrop color tracks the row state underneath the overlay. */
.gmail-row-shell:hover {
--gm-row-actions-bg: var(--gm-bg-row-hover);
}
.gmail-row-shell:hover:has(.gmail-row-selected) {
--gm-row-actions-bg: var(--gm-bg-row-selected-hover);
}
.gmail-row-shell:hover:has(.gmail-row-selected.gmail-row-focused) {
--gm-row-actions-bg: var(--gm-bg-row-selected);
}
.gmail-row-shell:hover .gmail-row-date {
visibility: hidden;
}
@ -364,7 +382,7 @@
color: var(--destructive);
}
.gmail-row:hover {
.gmail-row-shell:hover .gmail-row {
background: var(--gm-bg-row-hover);
box-shadow: none;
}
@ -374,20 +392,20 @@
box-shadow: inset 2px 0 0 var(--gm-accent);
}
.gmail-row-selected:hover {
.gmail-row-shell:hover .gmail-row-selected {
background: var(--gm-bg-row-selected-hover);
}
/* The j/k keyboard cursor. Declared after the hover rules (same specificity)
so the focus ring survives hovering the focused row. */
.gmail-row-focused,
.gmail-row-focused:hover {
.gmail-row-shell:hover .gmail-row-focused {
background: var(--gm-bg-row-hover);
box-shadow: inset 0 0 0 1px var(--gm-accent);
}
.gmail-row-selected.gmail-row-focused,
.gmail-row-selected.gmail-row-focused:hover {
.gmail-row-shell:hover .gmail-row-selected.gmail-row-focused {
background: var(--gm-bg-row-selected);
box-shadow: inset 2px 0 0 var(--gm-accent), inset 0 0 0 1px var(--gm-accent);
}

View file

@ -1,5 +1,5 @@
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, X } from 'lucide-react'
import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, Redo2, RefreshCw, Reply, ReplyAll, Search, Send, Sparkles, SquarePen, Star, StarOff, Strikethrough, Trash2, Undo2, X } from 'lucide-react'
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
@ -2149,10 +2149,12 @@ const ThreadRow = memo(function ThreadRow({
isMounted,
isLeaving,
keysDisabled,
section,
onToggle,
onMarkRead,
onArchive,
onTrash,
onSetImportance,
onHoverIn,
onHoverOut,
onCloseThread,
@ -2164,10 +2166,13 @@ const ThreadRow = memo(function ThreadRow({
isMounted: boolean
isLeaving: boolean
keysDisabled: boolean
/** Which inbox section the row is rendered in; null hides the importance toggle (e.g. search results). */
section: 'important' | 'other' | null
onToggle: (thread: GmailThread) => void
onMarkRead: (threadId: string, read?: boolean) => Promise<void>
onArchive: (threadId: string) => Promise<void>
onTrash: (threadId: string) => Promise<void>
onSetImportance: (threadId: string, importance: 'important' | 'other') => Promise<void>
onHoverIn: (thread: GmailThread) => void
onHoverOut: () => void
onCloseThread: () => void
@ -2200,6 +2205,17 @@ const ThreadRow = memo(function ThreadRow({
<span className="gmail-row-date">{formatInboxTime(latest?.date || thread.date)}</span>
</button>
<div className="gmail-row-actions" onMouseDown={stop} onClick={stop}>
{section && (
<button
type="button"
className="gmail-row-action"
title={section === 'important' ? 'Not important — teach the classifier' : 'Important — teach the classifier'}
aria-label={section === 'important' ? 'Mark as not important' : 'Mark as important'}
onClick={(e) => { stop(e); void onSetImportance(thread.threadId, section === 'important' ? 'other' : 'important') }}
>
{section === 'important' ? <StarOff size={15} /> : <Star size={15} />}
</button>
)}
<button
type="button"
className="gmail-row-action"
@ -2297,6 +2313,7 @@ function ShortcutsHelpDialog({ open, onOpenChange }: { open: boolean; onOpenChan
<ShortcutRow combo={['E']} label="Archive" />
<ShortcutRow combo={['#']} label="Move to trash" />
<ShortcutRow combo={['U']} label="Mark read / unread" />
<ShortcutRow combo={['I']} label="Toggle important (teaches the classifier)" />
<ShortcutRow combo={['C']} alt={['N']} label="New message" />
<ShortcutRow combo={['/']} label="Search" />
<ShortcutRow combo={['G', 'I']} label="Go to inbox" />
@ -2590,6 +2607,37 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
}
}, [removeThreadFromState, markLeaving])
// User flips a thread's verdict: sticky on the thread + recorded as a
// correction the importance classifier learns from. The row slides out of
// its current section and lands on top of the other one.
const setImportanceAction = useCallback(async (threadId: string, importance: 'important' | 'other') => {
const source = [...important.threads, ...other.threads].find((t) => t.threadId === threadId)
markLeaving(threadId, true)
try {
const [result] = await Promise.all([
window.ipc.invoke('gmail:setImportance', { threadId, importance }),
new Promise((resolve) => window.setTimeout(resolve, ROW_LEAVE_MS)),
])
if (result.ok) {
const from = importance === 'important' ? 'other' as const : 'important' as const
setSection(from, (prev) => ({ ...prev, threads: prev.threads.filter((t) => t.threadId !== threadId) }))
if (source) {
setSection(importance, (prev) => ({
...prev,
threads: [source, ...prev.threads.filter((t) => t.threadId !== threadId)],
}))
}
toast(importance === 'important' ? 'Marked important — noted for future emails.' : 'Marked not important — noted for future emails.', 'success')
} else if (result.error) {
toast(`Could not update importance: ${result.error}`, 'error')
}
} catch (err) {
toast(`Could not update importance: ${err instanceof Error ? err.message : String(err)}`, 'error')
} finally {
markLeaving(threadId, false)
}
}, [important.threads, other.threads, markLeaving, setSection])
const trashThreadAction = useCallback(async (threadId: string) => {
markLeaving(threadId, true)
try {
@ -3102,7 +3150,8 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
return
case 'e':
case '#':
case 'u': {
case 'u':
case 'i': {
if (listMode === 'drafts') return // drafts have no archive/trash/read state
// The open thread takes precedence over the cursor.
const targetId = selectedThreadId ?? (focusedIndex >= 0 ? visibleList[focusedIndex]?.threadId : undefined)
@ -3111,6 +3160,10 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
e.preventDefault()
if (e.key === 'u') void markThreadReadAction(target.threadId, target.unread === true)
else if (e.key === 'e') void archiveThreadAction(target.threadId)
else if (e.key === 'i') {
const isImportant = important.threads.some((t) => t.threadId === target.threadId)
void setImportanceAction(target.threadId, isImportant ? 'other' : 'important')
}
else void trashThreadAction(target.threadId)
return
}
@ -3121,7 +3174,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
}, [
visibleList, focusedThreadId, selectedThreadId, query, listMode, anyModalOpen,
activeThreadComposing, rowIdOf, toggleThread, markThreadReadAction,
archiveThreadAction, trashThreadAction,
archiveThreadAction, trashThreadAction, setImportanceAction, important.threads,
])
const hasAny = important.threads.length > 0 || other.threads.length > 0
@ -3131,7 +3184,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
const closeThread = useCallback(() => setSelectedThreadId(null), [])
const renderRow = (thread: GmailThread) => {
const renderRow = (thread: GmailThread, section: 'important' | 'other' | null = null) => {
const isMounted = openedThreadIds.includes(thread.threadId)
return (
<ThreadRow
@ -3142,10 +3195,12 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
isMounted={isMounted}
isLeaving={leavingThreadIds.has(thread.threadId)}
keysDisabled={isMounted && anyModalOpen}
section={section}
onToggle={toggleThread}
onMarkRead={markThreadReadAction}
onArchive={archiveThreadAction}
onTrash={trashThreadAction}
onSetImportance={setImportanceAction}
onHoverIn={scheduleHoverPrefetch}
onHoverOut={cancelHoverPrefetch}
onCloseThread={closeThread}
@ -3260,7 +3315,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
<span>Search results</span>
<span>{searchResults.length} thread{searchResults.length === 1 ? '' : 's'}</span>
</div>
{searchResults.map(renderRow)}
{searchResults.map((t) => renderRow(t))}
</section>
</div>
) : (
@ -3298,7 +3353,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
{important.threads.length}{important.hasReachedEnd ? '' : '+'} thread{important.threads.length === 1 ? '' : 's'}
</span>
</div>
{visibleImportant.map(renderRow)}
{visibleImportant.map((t) => renderRow(t, 'important'))}
{!important.hasReachedEnd && (
<SectionSentinel
disabled={important.loadingPage || important.hasReachedEnd}
@ -3316,7 +3371,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
{other.threads.length}{other.hasReachedEnd ? '' : '+'} thread{other.threads.length === 1 ? '' : 's'}
</span>
</div>
{visibleOther.map(renderRow)}
{visibleOther.map((t) => renderRow(t, 'other'))}
{!other.hasReachedEnd && (
<SectionSentinel
disabled={other.loadingPage || other.hasReachedEnd}