email syncyng
This commit is contained in:
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
+29
-29
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user