diff --git a/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx b/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx index d75770f9..f1166a3d 100644 --- a/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx @@ -49,7 +49,7 @@ export const EmailList = () => { const handleSync = async () => { try { - await createJob({ lane: 'google-api', type: 'gmail-sync' }); + await createJob({ lane: 'google-api', type: 'gmail-sync', notify: false }); toast.success('Gmail sync started'); } catch { toast.error('Failed to start sync'); diff --git a/src/servers/api/queue/queue.ts b/src/servers/api/queue/queue.ts index 99eab8d3..1d33531a 100644 --- a/src/servers/api/queue/queue.ts +++ b/src/servers/api/queue/queue.ts @@ -29,9 +29,14 @@ queueRouter.get('/jobs/:id', async (ctx) => { queueRouter.post('/jobs', async (ctx) => { const user = ctx.get('user'); const body = ctx.get('body'); - const { lane, type, meta } = body as { lane: string; type: string; meta?: Record }; + const { lane, type, meta, notify } = body as { + lane: string; + type: string; + meta?: Record; + notify?: boolean; + }; - const job = await enqueue({ lane, type, userId: user.email, meta }); + const job = await enqueue({ lane, type, userId: user.email, meta, notify }); return ctx.json(job, 201); }); diff --git a/src/servers/queue/engine.ts b/src/servers/queue/engine.ts index 31d63c0b..c32590ca 100644 --- a/src/servers/queue/engine.ts +++ b/src/servers/queue/engine.ts @@ -21,6 +21,7 @@ export async function enqueue(params: EnqueueParams): Promise { currentStep: 0, createdAt: Date.now(), meta: params.meta, + notify: params.notify, }; await writeJob(job); @@ -175,7 +176,7 @@ async function runJob(job: Job) { fresh.completedAt = Date.now(); await writeJob(fresh); console.error(`[queue] Job ${fresh.id} failed at step "${step.name}":`, errorMessage); - await notifyFailure(fresh); + if (fresh.notify !== false) await notifyFailure(fresh); return; } } @@ -186,7 +187,7 @@ async function runJob(job: Job) { final.completedAt = Date.now(); await writeJob(final); console.log(`[queue] Job ${final.id} completed`); - await notifyCompletion(final); + if (final.notify !== false) await notifyCompletion(final); } } diff --git a/src/servers/queue/handlers/gmail-sync.ts b/src/servers/queue/handlers/gmail-sync.ts index 2d10651c..b7397f8c 100644 --- a/src/servers/queue/handlers/gmail-sync.ts +++ b/src/servers/queue/handlers/gmail-sync.ts @@ -383,7 +383,8 @@ const gmailSyncHandler: JobHandler = { if (year) { // Year-scoped sync: count total emails first, then sync month by month - const yearQuery = `after:${year}/1/1 before:${year + 1}/1/1`; + const labelScope = '(in:inbox OR in:sent OR in:trash OR in:spam)'; + const yearQuery = `${labelScope} after:${year}/1/1 before:${year + 1}/1/1`; await ctx.updateProgress({ current: 0, total: 0, label: 'Counting emails...' }); const totalEmails = await countMessages(token, yearQuery); console.log(`[gmail-sync] Pre-flight: ${totalEmails} emails for ${year}`); @@ -392,7 +393,7 @@ const gmailSyncHandler: JobHandler = { for (let i = 0; i < months.length; i++) { const month = months[i]!; await ctx.updateProgress({ current: totalSaved + totalSkipped, total: totalEmails, label: month.label }); - const query = `after:${month.after} before:${month.before}`; + const query = `${labelScope} after:${month.after} before:${month.before}`; const result = await syncInbox(token, db, ctx.job.userId, query, (p) => { const current = totalSaved + p.saved + p.skipped + p.errors; const label = `${month.label} — Saved ${(totalSaved + p.saved).toLocaleString()} of ${totalEmails.toLocaleString()}`; @@ -410,12 +411,14 @@ const gmailSyncHandler: JobHandler = { } await ctx.updateProgress({ current: totalEmails, total: totalEmails, label: 'Done' }); } else { - // If we have a last_sync_date (e.g. from migration), scope the sync to only newer emails + // Scope to inbox + sent to avoid syncing trash/spam/drafts const lastSyncDate = getSyncMeta(db, 'last_sync_date'); - let syncQuery: string | undefined; + const labelScope = 'in:inbox OR in:sent OR in:trash OR in:spam'; + let syncQuery: string = labelScope; if (lastSyncDate) { const d = new Date(lastSyncDate); - syncQuery = `after:${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`; + const dateScope = `after:${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`; + syncQuery = `(${labelScope}) ${dateScope}`; console.log(`[gmail-sync] Scoping full sync with query: ${syncQuery}`); } diff --git a/src/servers/queue/types.ts b/src/servers/queue/types.ts index 054a0b42..9a39eef5 100644 --- a/src/servers/queue/types.ts +++ b/src/servers/queue/types.ts @@ -30,6 +30,7 @@ export type Job = { startedAt?: number; completedAt?: number; meta?: Record; + notify?: boolean; }; export type StepContext = { @@ -54,4 +55,5 @@ export type EnqueueParams = { type: string; userId: string; meta?: Record; + notify?: boolean; }; diff --git a/src/workspaces/hooks/src/useJobs.ts b/src/workspaces/hooks/src/useJobs.ts index 98375ec5..3fd1992c 100644 --- a/src/workspaces/hooks/src/useJobs.ts +++ b/src/workspaces/hooks/src/useJobs.ts @@ -35,7 +35,7 @@ export const useJobs = (filters?: JobFilters) => { const invalidate = () => queryClient.invalidateQueries({ queryKey: ['jobs'] }); const createMutation = useMutation({ - mutationFn: (params: { lane: string; type: string; meta?: Record }) => + mutationFn: (params: { lane: string; type: string; meta?: Record; notify?: boolean }) => client.post('/queue/jobs', params), onSuccess: invalidate, }); diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx index 21eb40ef..14c7df17 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx @@ -9,6 +9,7 @@ import { getProviderDisplayName } from 'state/useModels'; type ChatSessionSelection = { sessionId: string | null; model?: string | null; + workspaceId?: string; }; function formatModel(model: string): string { @@ -35,18 +36,27 @@ export const ChatHeader = () => { const [open, setOpen] = useState(false); const autoResumedRef = useRef(false); - // Auto-resume the latest session on mount + // Clear stale selection from a different workspace useEffect(() => { - if (autoResumedRef.current || selection) return; + if (selection && selection.workspaceId !== workspaceId) { + setSelection(null); + autoResumedRef.current = false; + } + }, [workspaceId, selection]); + + // Auto-resume the latest session for this workspace + useEffect(() => { + if (autoResumedRef.current) return; + if (selection && selection.workspaceId === workspaceId) return; if (sessions.length > 0) { const latest = sessions[0]!; - setSelection({ sessionId: latest.id, model: latest.model ?? null }); + setSelection({ sessionId: latest.id, model: latest.model ?? null, workspaceId }); autoResumedRef.current = true; } - }, [sessions, selection]); + }, [sessions, selection, workspaceId]); const selectSession = (sessionId: string | null, model?: string | null) => { - setSelection({ sessionId, model }); + setSelection({ sessionId, model, workspaceId }); setOpen(false); }; diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx index d168d131..54ac767e 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { useWorkspace } from '../../components/Workspace'; import { usePanelChannel } from 'hooks/usePanelChannel'; import { usePiChat } from '../../hooks/usePiChat'; @@ -7,6 +7,7 @@ import { EmbeddableChat } from './EmbeddableChat'; type ChatSessionSelection = { sessionId: string | null; model?: string | null; + workspaceId?: string; }; type ChatPanelInnerProps = { @@ -18,10 +19,11 @@ type ChatPanelInnerProps = { promptPrefix?: string; chatContext: Record; setActiveSession: (id: string | null) => void; + onTurnComplete?: (hadToolCalls: boolean) => void; }; -const ChatPanelInner = ({ sessionId, model, scoped, sandboxed, cwdParam, promptPrefix, chatContext, setActiveSession }: ChatPanelInnerProps) => { - const chat = usePiChat(sessionId, model, { replaceUrl: false, projectScoped: scoped, ...chatContext }); +const ChatPanelInner = ({ sessionId, model, scoped, sandboxed, cwdParam, promptPrefix, chatContext, setActiveSession, onTurnComplete }: ChatPanelInnerProps) => { + const chat = usePiChat(sessionId, model, { replaceUrl: false, projectScoped: scoped, onTurnComplete, ...chatContext }); useEffect(() => { setActiveSession(chat.sessionId); @@ -58,11 +60,17 @@ export const ChatPanelWrapper = () => { const [selection] = usePanelChannel('chat:panel-session', null); const [, setActiveSession] = usePanelChannel('chat:active-session', null); + const [, setPreviewRefresh] = usePanelChannel('preview:refresh', 0); + + const onTurnComplete = useCallback((hadToolCalls: boolean) => { + if (hadToolCalls) setPreviewRefresh(Date.now()); + }, [setPreviewRefresh]); const cwdParam = scoped ? { root, path: cwd } : undefined; - const sessionId = selection?.sessionId ?? undefined; - const model = selection?.model ?? undefined; + const isCurrentWorkspace = !selection?.workspaceId || selection.workspaceId === workspaceId; + const sessionId = isCurrentWorkspace ? selection?.sessionId ?? undefined : undefined; + const model = isCurrentWorkspace ? selection?.model ?? undefined : undefined; return ( { promptPrefix={promptPrefix} chatContext={chatContext} setActiveSession={setActiveSession} + onTurnComplete={onTurnComplete} /> ); }; diff --git a/src/workspaces/officerdev/src/apps/Preview/PreviewHeader.tsx b/src/workspaces/officerdev/src/apps/Preview/PreviewHeader.tsx index 673e4537..1496a38b 100644 --- a/src/workspaces/officerdev/src/apps/Preview/PreviewHeader.tsx +++ b/src/workspaces/officerdev/src/apps/Preview/PreviewHeader.tsx @@ -2,7 +2,7 @@ import { Globe, RefreshCw, Square, Play } from 'lucide-react'; import { usePreview } from './PreviewContext'; export const PreviewHeader = () => { - const { slug, url, port, stopped, isSuperAdmin, refresh, stopServer, restartServer } = usePreview(); + const { slug, url, port, stopped, isSuperAdmin, stopServer, restartServer } = usePreview(); return ( <> @@ -12,7 +12,7 @@ export const PreviewHeader = () => { <>