email syncyng

This commit is contained in:
2026-02-26 01:39:38 +00:00
parent 5d4f0114cd
commit 42abb97d7b
22 changed files with 1422 additions and 348 deletions
@@ -0,0 +1,17 @@
import { EmbeddableChat, usePiChat } from 'officerdev';
const PROMPT_PREFIX = `You are an email assistant. The user has a local SQLite email database available via the "email-db" tool — use it for all email queries (search, count, stats, aggregations, deletions) unless the user explicitly asks you to use Gmail. Do not use the Gmail integration for questions about existing emails.`;
export const EmailChat = () => {
const chat = usePiChat(undefined, undefined, { replaceUrl: false });
return (
<EmbeddableChat
className="h-full"
chat={chat}
sandboxed
replaceUrl={false}
promptPrefix={PROMPT_PREFIX}
/>
);
};
@@ -1,8 +1,10 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ChevronLeft, ChevronRight, Mail, Paperclip } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ChevronLeft, ChevronRight, Loader2, Mail, Paperclip, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
import { useGlobal } from 'hooks/useGlobal';
import { useJobs } from 'hooks/useJobs';
import type { EmailSummary } from 'types';
const LIMIT = 50;
@@ -19,8 +21,11 @@ const formatDate = (iso: string) => {
export const EmailList = () => {
const client = useClient();
const queryClient = useQueryClient();
const [selectedId, setSelectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
const [page, setPage] = useState(1);
const { jobs, createJob } = useJobs({ type: 'gmail-sync' });
const isSyncing = jobs.some((j) => j.status === 'queued' || j.status === 'running');
const { data, isLoading } = useQuery({
queryKey: ['email-messages', page],
@@ -28,6 +33,24 @@ export const EmailList = () => {
client.get<{ messages: EmailSummary[]; total: number }>(`/email/messages?page=${page}&limit=${LIMIT}`),
});
const handleSync = async () => {
try {
await createJob({ lane: 'google-api', type: 'gmail-sync' });
toast.success('Gmail sync started');
} catch {
toast.error('Failed to start sync');
}
};
// Refresh email list when a sync job completes
const prevSyncing = useRef(false);
useEffect(() => {
if (prevSyncing.current && !isSyncing) {
queryClient.invalidateQueries({ queryKey: ['email-messages'] });
}
prevSyncing.current = isSyncing;
}, [isSyncing, queryClient]);
const messages = data?.messages ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / LIMIT));
@@ -55,6 +78,18 @@ export const EmailList = () => {
<Mail className="h-4 w-4 opacity-60" />
<span className="text-sm font-medium">Inbox</span>
<span className="text-xs opacity-50">{total}</span>
<button
onClick={handleSync}
disabled={isSyncing}
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer disabled:cursor-default disabled:opacity-50 shrink-0"
title={isSyncing ? 'Syncing...' : 'Sync emails'}
>
{isSyncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
</button>
{totalPages > 1 && (
<div className="ml-auto flex items-center gap-2">
<button
@@ -6,6 +6,7 @@ import { useGlobal } from 'hooks/useGlobal';
import { defaultLayout } from './defaultLayout';
import { EmailList } from './EmailList';
import { EmailReader } from './EmailReader';
import { EmailChat } from './EmailChat';
export const EmailScreen = () => {
const isMobile = useIsMobile();
@@ -15,6 +16,7 @@ export const EmailScreen = () => {
() => ({
'email-list': EmailList,
'email-reader': EmailReader,
'email-chat': EmailChat,
}),
[],
);
@@ -5,7 +5,18 @@ export const defaultLayout: LayoutNode = {
id: 'email-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'email-list', appType: null }, size: 30 },
{ node: { type: 'panel', id: 'email-reader', appType: null }, size: 70 },
{ node: { type: 'panel', id: 'email-list', appType: null }, size: 25 },
{
node: {
type: 'group',
id: 'email-right',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'email-reader', appType: null }, size: 60 },
{ node: { type: 'panel', id: 'email-chat', appType: null }, size: 40 },
],
},
size: 75,
},
],
};
@@ -12,7 +12,7 @@ type GoogleStatus = {
configured: boolean;
};
const formatTime = (ts: number) => {
const formatTime = (ts: number | string) => {
const d = new Date(ts);
return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
};
@@ -21,6 +21,7 @@ export const GoogleAccount = () => {
const client = useClient();
const [isLoading, setIsLoading] = useState(true);
const [status, setStatus] = useState<GoogleStatus>({ connected: false, email: null, picture: null, configured: false });
const [lastSyncAt, setLastSyncAt] = useState<string | null>(null);
const { jobs, createJob, refetch } = useJobs({ type: 'gmail-sync' });
const activeJob = jobs.find((j) => j.status === 'queued' || j.status === 'running');
const lastJob = jobs[0];
@@ -31,6 +32,10 @@ export const GoogleAccount = () => {
.then(setStatus)
.catch(() => {})
.finally(() => setIsLoading(false));
client
.get<{ lastSyncAt: string | null }>('/email/sync-status')
.then((res) => setLastSyncAt(res.lastSyncAt))
.catch(() => {});
};
useEffect(() => {
@@ -47,6 +52,13 @@ export const GoogleAccount = () => {
}
}, []);
// Refresh sync status from DB when a job finishes
useEffect(() => {
if (!activeJob && lastJob?.status === 'completed') {
client.get<{ lastSyncAt: string | null }>('/email/sync-status').then((res) => setLastSyncAt(res.lastSyncAt)).catch(() => {});
}
}, [activeJob, lastJob?.status]);
const handleSync = async (year?: number) => {
try {
await createJob({ lane: 'google-api', type: 'gmail-sync', meta: year ? { year } : undefined });
@@ -131,40 +143,28 @@ export const GoogleAccount = () => {
</div>
</div>
)}
{!activeJob && lastJob?.status === 'completed' && (
<div className="flex items-center gap-2 text-xs text-duck-dark/50 dark:text-foreground/50">
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
Last sync completed {formatTime(lastJob.completedAt!)}
</div>
)}
{!activeJob && lastJob?.status === 'failed' && (
<div className="flex items-center gap-2 text-xs text-red-500">
<XCircle className="h-3.5 w-3.5 shrink-0" />
Last sync failed{lastJob.error ? `: ${lastJob.error}` : ''}
</div>
)}
<div className="flex gap-2">
<Button
type="button"
variant="outline"
disabled={!!activeJob}
onClick={() => handleSync()}
className="flex-1 h-11 cursor-pointer gap-2"
>
<RefreshCw className="h-4 w-4" />
Sync Gmail Inbox
</Button>
<Button
type="button"
variant="outline"
disabled={!!activeJob}
onClick={() => handleSync(2026)}
className="flex-1 h-11 cursor-pointer gap-2"
>
<RefreshCw className="h-4 w-4" />
Test Sync (2026)
</Button>
</div>
{!activeJob && lastSyncAt && (
<div className="flex items-center gap-2 text-xs text-duck-dark/50 dark:text-foreground/50">
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
Last sync completed {formatTime(lastSyncAt)}
</div>
)}
<Button
type="button"
variant="outline"
disabled={!!activeJob}
onClick={() => handleSync()}
className="w-full h-11 cursor-pointer gap-2"
>
<RefreshCw className="h-4 w-4" />
Sync Gmail Inbox
</Button>
<Button
type="button"
variant="outline"