added archive and delete

This commit is contained in:
Arjun 2026-05-14 21:40:08 +05:30
parent c0266d4583
commit 3adcea4b2c
5 changed files with 252 additions and 17 deletions

View file

@ -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 } from '@x/core/dist/knowledge/sync_gmail.js';
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead } 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,15 @@ export function setupIpcHandlers() {
'gmail:sendReply': async (_event, args) => {
return sendThreadReply(args);
},
'gmail:archiveThread': async (_event, args) => {
return archiveThread(args.threadId);
},
'gmail:trashThread': async (_event, args) => {
return trashThread(args.threadId);
},
'gmail:markThreadRead': async (_event, args) => {
return markThreadRead(args.threadId);
},
'gmail:saveMessageHeight': async (_event, args) => {
saveMessageBodyHeight(args.threadId, args.messageId, args.height);
return {};

View file

@ -232,6 +232,10 @@
color: var(--gm-text-faint);
}
.gmail-row-shell {
position: relative;
}
.gmail-row {
display: grid;
grid-template-columns: 12px minmax(140px, 0.22fr) minmax(0, 1fr) 60px;
@ -249,6 +253,51 @@
transition: background 120ms ease;
}
.gmail-row-actions {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 2px;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
}
.gmail-row-shell:hover .gmail-row-actions {
opacity: 1;
pointer-events: auto;
}
.gmail-row-shell:hover .gmail-row-date {
visibility: hidden;
}
.gmail-row-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--gm-text-muted);
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.gmail-row-action:hover {
background: var(--gm-bg-pill-hover);
color: var(--gm-text-strong);
}
.gmail-row-action-danger:hover {
color: #e8453c;
}
.gmail-row:hover {
background: var(--gm-bg-row-hover);
box-shadow: none;

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Bold, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, RefreshCw, Reply, Search, Send, Sparkles, Strikethrough } from 'lucide-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 { useEditor, EditorContent, type Editor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
@ -890,18 +890,75 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
else setOther(updater)
}, [])
const toggleThread = useCallback((threadId: string) => {
const updateThreadInState = useCallback((threadId: string, updater: (t: GmailThread) => GmailThread) => {
const mapSection = (prev: SectionState): SectionState => ({
...prev,
threads: prev.threads.map((t) => (t.threadId === threadId ? updater(t) : t)),
})
setImportant(mapSection)
setOther(mapSection)
}, [])
const removeThreadFromState = useCallback((threadId: string) => {
const filterSection = (prev: SectionState): SectionState => ({
...prev,
threads: prev.threads.filter((t) => t.threadId !== threadId),
})
setImportant(filterSection)
setOther(filterSection)
setSelectedThreadId((current) => (current === threadId ? null : current))
setOpenedThreadIds((prev) => prev.filter((id) => id !== threadId))
}, [])
const markThreadReadAction = useCallback(async (threadId: string) => {
updateThreadInState(threadId, (t) => ({
...t,
unread: false,
messages: t.messages.map((m) => ({ ...m, unread: false })),
}))
try {
const result = await window.ipc.invoke('gmail:markThreadRead', { threadId })
if (!result.ok && result.error) console.warn('[Gmail] mark-read failed:', result.error)
} catch (err) {
console.warn('[Gmail] mark-read failed:', err)
}
}, [updateThreadInState])
const archiveThreadAction = useCallback(async (threadId: string) => {
removeThreadFromState(threadId)
try {
const result = await window.ipc.invoke('gmail:archiveThread', { threadId })
if (!result.ok && result.error) toast(`Archive failed: ${result.error}`, 'error')
} catch (err) {
toast(`Archive failed: ${err instanceof Error ? err.message : String(err)}`, 'error')
}
}, [removeThreadFromState])
const trashThreadAction = useCallback(async (threadId: string) => {
removeThreadFromState(threadId)
try {
const result = await window.ipc.invoke('gmail:trashThread', { threadId })
if (!result.ok && result.error) toast(`Delete failed: ${result.error}`, 'error')
} catch (err) {
toast(`Delete failed: ${err instanceof Error ? err.message : String(err)}`, 'error')
}
}, [removeThreadFromState])
const toggleThread = useCallback((thread: GmailThread) => {
setSelectedThreadId((current) => {
const next = current === threadId ? null : threadId
const next = current === thread.threadId ? null : thread.threadId
if (next) {
setOpenedThreadIds((prev) => {
const without = prev.filter((id) => id !== next)
return [...without, next].slice(-MAX_KEPT_OPEN)
})
if (thread.unread) {
void markThreadReadAction(thread.threadId)
}
}
return next
})
}, [])
}, [markThreadReadAction])
const prefetchedRef = useRef<Set<string>>(new Set())
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@ -1154,23 +1211,61 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
const isSelected = thread.threadId === selectedThreadId
const isUnread = thread.unread === true
const isMounted = openedThreadIds.includes(thread.threadId)
const stop = (e: React.MouseEvent | React.KeyboardEvent) => {
e.stopPropagation()
}
return (
<div key={thread.threadId} className="gmail-row-group">
<button
type="button"
className={cn('gmail-row', isSelected && 'gmail-row-selected', isUnread && 'gmail-row-unread')}
onClick={() => toggleThread(thread.threadId)}
<div
className={cn('gmail-row-shell', isSelected && 'gmail-row-shell-selected')}
onMouseEnter={() => scheduleHoverPrefetch(thread)}
onMouseLeave={cancelHoverPrefetch}
>
<span className="gmail-row-dot" aria-hidden />
<span className="gmail-row-sender">{extractName(latest?.from || thread.from)}</span>
<span className="gmail-row-content">
<strong>{thread.summary || thread.subject || '(No subject)'}</strong>
<span>{thread.summary ? thread.subject : snippet(latest?.body || thread.latest_email)}</span>
</span>
<span className="gmail-row-date">{formatInboxTime(latest?.date || thread.date)}</span>
</button>
<button
type="button"
className={cn('gmail-row', isSelected && 'gmail-row-selected', isUnread && 'gmail-row-unread')}
onClick={() => toggleThread(thread)}
>
<span className="gmail-row-dot" aria-hidden />
<span className="gmail-row-sender">{extractName(latest?.from || thread.from)}</span>
<span className="gmail-row-content">
<strong>{thread.summary || thread.subject || '(No subject)'}</strong>
<span>{thread.summary ? thread.subject : snippet(latest?.body || thread.latest_email)}</span>
</span>
<span className="gmail-row-date">{formatInboxTime(latest?.date || thread.date)}</span>
</button>
<div className="gmail-row-actions" onMouseDown={stop} onClick={stop}>
{isUnread && (
<button
type="button"
className="gmail-row-action"
title="Mark as read"
aria-label="Mark as read"
onClick={(e) => { stop(e); void markThreadReadAction(thread.threadId) }}
>
<CheckCheck size={15} />
</button>
)}
<button
type="button"
className="gmail-row-action"
title="Archive"
aria-label="Archive"
onClick={(e) => { stop(e); void archiveThreadAction(thread.threadId) }}
>
<Archive size={15} />
</button>
<button
type="button"
className="gmail-row-action gmail-row-action-danger"
title="Delete"
aria-label="Delete"
onClick={(e) => { stop(e); void trashThreadAction(thread.threadId) }}
>
<Trash2 size={15} />
</button>
</div>
</div>
{isMounted && (
<ThreadDetail
thread={thread}

View file

@ -78,6 +78,76 @@ export function saveMessageBodyHeight(threadId: string, messageId: string, heigh
}
}
function deleteCachedSnapshot(threadId: string): void {
try {
fs.rmSync(cachePath(threadId), { force: true });
} catch (err) {
console.warn(`[Gmail cache] delete failed for ${threadId}:`, err);
}
}
async function getGmailClientOrThrow() {
const auth = await GoogleClientFactory.getClient();
if (!auth) throw new Error('Gmail is not connected.');
return google.gmail({ version: 'v1', auth });
}
export interface ThreadActionResult {
ok: boolean;
error?: string;
}
export async function archiveThread(threadId: string): Promise<ThreadActionResult> {
try {
const gmailClient = await getGmailClientOrThrow();
await gmailClient.users.threads.modify({
userId: 'me',
id: threadId,
requestBody: { removeLabelIds: ['INBOX'] },
});
deleteCachedSnapshot(threadId);
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function trashThread(threadId: string): Promise<ThreadActionResult> {
try {
const gmailClient = await getGmailClientOrThrow();
await gmailClient.users.threads.trash({ userId: 'me', id: threadId });
deleteCachedSnapshot(threadId);
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function markThreadRead(threadId: string): Promise<ThreadActionResult> {
try {
const gmailClient = await getGmailClientOrThrow();
await gmailClient.users.threads.modify({
userId: 'me',
id: threadId,
requestBody: { removeLabelIds: ['UNREAD'] },
});
// Update local cache: clear unread on all messages in the thread.
const cached = readCachedSnapshot(threadId);
if (cached) {
for (const m of cached.snapshot.messages) m.unread = false;
cached.snapshot.unread = false;
try {
fs.writeFileSync(cachePath(threadId), JSON.stringify(cached), 'utf-8');
} catch (err) {
console.warn(`[Gmail cache] markRead write failed for ${threadId}:`, err);
}
}
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
interface SyncedThread {
threadId: string;
markdown: string;

View file

@ -163,6 +163,18 @@ const ipcSchemas = {
error: z.string().optional(),
}),
},
'gmail:archiveThread': {
req: z.object({ threadId: z.string().min(1) }),
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
},
'gmail:trashThread': {
req: z.object({ threadId: z.string().min(1) }),
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
},
'gmail:markThreadRead': {
req: z.object({ threadId: z.string().min(1) }),
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
},
'gmail:saveMessageHeight': {
req: z.object({
threadId: z.string().min(1),