preview auto-refresh after chat tool calls, gmail sync label scoping, queue notify option, workspace-scoped chat sessions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 01:49:52 +00:00
co-authored by Claude Opus 4.6
parent 968d502eaa
commit 269b1026e5
11 changed files with 80 additions and 28 deletions
@@ -49,7 +49,7 @@ export const EmailList = () => {
const handleSync = async () => { const handleSync = async () => {
try { try {
await createJob({ lane: 'google-api', type: 'gmail-sync' }); await createJob({ lane: 'google-api', type: 'gmail-sync', notify: false });
toast.success('Gmail sync started'); toast.success('Gmail sync started');
} catch { } catch {
toast.error('Failed to start sync'); toast.error('Failed to start sync');
+7 -2
View File
@@ -29,9 +29,14 @@ queueRouter.get('/jobs/:id', async (ctx) => {
queueRouter.post('/jobs', async (ctx) => { queueRouter.post('/jobs', async (ctx) => {
const user = ctx.get('user'); const user = ctx.get('user');
const body = ctx.get('body'); const body = ctx.get('body');
const { lane, type, meta } = body as { lane: string; type: string; meta?: Record<string, unknown> }; const { lane, type, meta, notify } = body as {
lane: string;
type: string;
meta?: Record<string, unknown>;
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); return ctx.json(job, 201);
}); });
+3 -2
View File
@@ -21,6 +21,7 @@ export async function enqueue(params: EnqueueParams): Promise<Job> {
currentStep: 0, currentStep: 0,
createdAt: Date.now(), createdAt: Date.now(),
meta: params.meta, meta: params.meta,
notify: params.notify,
}; };
await writeJob(job); await writeJob(job);
@@ -175,7 +176,7 @@ async function runJob(job: Job) {
fresh.completedAt = Date.now(); fresh.completedAt = Date.now();
await writeJob(fresh); await writeJob(fresh);
console.error(`[queue] Job ${fresh.id} failed at step "${step.name}":`, errorMessage); console.error(`[queue] Job ${fresh.id} failed at step "${step.name}":`, errorMessage);
await notifyFailure(fresh); if (fresh.notify !== false) await notifyFailure(fresh);
return; return;
} }
} }
@@ -186,7 +187,7 @@ async function runJob(job: Job) {
final.completedAt = Date.now(); final.completedAt = Date.now();
await writeJob(final); await writeJob(final);
console.log(`[queue] Job ${final.id} completed`); console.log(`[queue] Job ${final.id} completed`);
await notifyCompletion(final); if (final.notify !== false) await notifyCompletion(final);
} }
} }
+8 -5
View File
@@ -383,7 +383,8 @@ const gmailSyncHandler: JobHandler = {
if (year) { if (year) {
// Year-scoped sync: count total emails first, then sync month by month // 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...' }); await ctx.updateProgress({ current: 0, total: 0, label: 'Counting emails...' });
const totalEmails = await countMessages(token, yearQuery); const totalEmails = await countMessages(token, yearQuery);
console.log(`[gmail-sync] Pre-flight: ${totalEmails} emails for ${year}`); 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++) { for (let i = 0; i < months.length; i++) {
const month = months[i]!; const month = months[i]!;
await ctx.updateProgress({ current: totalSaved + totalSkipped, total: totalEmails, label: month.label }); 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 result = await syncInbox(token, db, ctx.job.userId, query, (p) => {
const current = totalSaved + p.saved + p.skipped + p.errors; const current = totalSaved + p.saved + p.skipped + p.errors;
const label = `${month.label} — Saved ${(totalSaved + p.saved).toLocaleString()} of ${totalEmails.toLocaleString()}`; 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' }); await ctx.updateProgress({ current: totalEmails, total: totalEmails, label: 'Done' });
} else { } 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'); 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) { if (lastSyncDate) {
const d = new Date(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}`); console.log(`[gmail-sync] Scoping full sync with query: ${syncQuery}`);
} }
+2
View File
@@ -30,6 +30,7 @@ export type Job = {
startedAt?: number; startedAt?: number;
completedAt?: number; completedAt?: number;
meta?: Record<string, unknown>; meta?: Record<string, unknown>;
notify?: boolean;
}; };
export type StepContext = { export type StepContext = {
@@ -54,4 +55,5 @@ export type EnqueueParams = {
type: string; type: string;
userId: string; userId: string;
meta?: Record<string, unknown>; meta?: Record<string, unknown>;
notify?: boolean;
}; };
+1 -1
View File
@@ -35,7 +35,7 @@ export const useJobs = (filters?: JobFilters) => {
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['jobs'] }); const invalidate = () => queryClient.invalidateQueries({ queryKey: ['jobs'] });
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (params: { lane: string; type: string; meta?: Record<string, unknown> }) => mutationFn: (params: { lane: string; type: string; meta?: Record<string, unknown>; notify?: boolean }) =>
client.post<Job>('/queue/jobs', params), client.post<Job>('/queue/jobs', params),
onSuccess: invalidate, onSuccess: invalidate,
}); });
@@ -9,6 +9,7 @@ import { getProviderDisplayName } from 'state/useModels';
type ChatSessionSelection = { type ChatSessionSelection = {
sessionId: string | null; sessionId: string | null;
model?: string | null; model?: string | null;
workspaceId?: string;
}; };
function formatModel(model: string): string { function formatModel(model: string): string {
@@ -35,18 +36,27 @@ export const ChatHeader = () => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const autoResumedRef = useRef(false); const autoResumedRef = useRef(false);
// Auto-resume the latest session on mount // Clear stale selection from a different workspace
useEffect(() => { 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) { if (sessions.length > 0) {
const latest = sessions[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; autoResumedRef.current = true;
} }
}, [sessions, selection]); }, [sessions, selection, workspaceId]);
const selectSession = (sessionId: string | null, model?: string | null) => { const selectSession = (sessionId: string | null, model?: string | null) => {
setSelection({ sessionId, model }); setSelection({ sessionId, model, workspaceId });
setOpen(false); setOpen(false);
}; };
@@ -1,4 +1,4 @@
import { useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { useWorkspace } from '../../components/Workspace'; import { useWorkspace } from '../../components/Workspace';
import { usePanelChannel } from 'hooks/usePanelChannel'; import { usePanelChannel } from 'hooks/usePanelChannel';
import { usePiChat } from '../../hooks/usePiChat'; import { usePiChat } from '../../hooks/usePiChat';
@@ -7,6 +7,7 @@ import { EmbeddableChat } from './EmbeddableChat';
type ChatSessionSelection = { type ChatSessionSelection = {
sessionId: string | null; sessionId: string | null;
model?: string | null; model?: string | null;
workspaceId?: string;
}; };
type ChatPanelInnerProps = { type ChatPanelInnerProps = {
@@ -18,10 +19,11 @@ type ChatPanelInnerProps = {
promptPrefix?: string; promptPrefix?: string;
chatContext: Record<string, string | undefined>; chatContext: Record<string, string | undefined>;
setActiveSession: (id: string | null) => void; setActiveSession: (id: string | null) => void;
onTurnComplete?: (hadToolCalls: boolean) => void;
}; };
const ChatPanelInner = ({ sessionId, model, scoped, sandboxed, cwdParam, promptPrefix, chatContext, setActiveSession }: ChatPanelInnerProps) => { const ChatPanelInner = ({ sessionId, model, scoped, sandboxed, cwdParam, promptPrefix, chatContext, setActiveSession, onTurnComplete }: ChatPanelInnerProps) => {
const chat = usePiChat(sessionId, model, { replaceUrl: false, projectScoped: scoped, ...chatContext }); const chat = usePiChat(sessionId, model, { replaceUrl: false, projectScoped: scoped, onTurnComplete, ...chatContext });
useEffect(() => { useEffect(() => {
setActiveSession(chat.sessionId); setActiveSession(chat.sessionId);
@@ -58,11 +60,17 @@ export const ChatPanelWrapper = () => {
const [selection] = usePanelChannel<ChatSessionSelection | null>('chat:panel-session', null); const [selection] = usePanelChannel<ChatSessionSelection | null>('chat:panel-session', null);
const [, setActiveSession] = usePanelChannel<string | null>('chat:active-session', null); const [, setActiveSession] = usePanelChannel<string | null>('chat:active-session', null);
const [, setPreviewRefresh] = usePanelChannel<number>('preview:refresh', 0);
const onTurnComplete = useCallback((hadToolCalls: boolean) => {
if (hadToolCalls) setPreviewRefresh(Date.now());
}, [setPreviewRefresh]);
const cwdParam = scoped ? { root, path: cwd } : undefined; const cwdParam = scoped ? { root, path: cwd } : undefined;
const sessionId = selection?.sessionId ?? undefined; const isCurrentWorkspace = !selection?.workspaceId || selection.workspaceId === workspaceId;
const model = selection?.model ?? undefined; const sessionId = isCurrentWorkspace ? selection?.sessionId ?? undefined : undefined;
const model = isCurrentWorkspace ? selection?.model ?? undefined : undefined;
return ( return (
<ChatPanelInner <ChatPanelInner
@@ -75,6 +83,7 @@ export const ChatPanelWrapper = () => {
promptPrefix={promptPrefix} promptPrefix={promptPrefix}
chatContext={chatContext} chatContext={chatContext}
setActiveSession={setActiveSession} setActiveSession={setActiveSession}
onTurnComplete={onTurnComplete}
/> />
); );
}; };
@@ -2,7 +2,7 @@ import { Globe, RefreshCw, Square, Play } from 'lucide-react';
import { usePreview } from './PreviewContext'; import { usePreview } from './PreviewContext';
export const PreviewHeader = () => { export const PreviewHeader = () => {
const { slug, url, port, stopped, isSuperAdmin, refresh, stopServer, restartServer } = usePreview(); const { slug, url, port, stopped, isSuperAdmin, stopServer, restartServer } = usePreview();
return ( return (
<> <>
@@ -12,7 +12,7 @@ export const PreviewHeader = () => {
<> <>
<button <button
type="button" type="button"
onClick={refresh} onClick={restartServer}
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0" className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0"
title="Refresh" title="Refresh"
> >
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useWorkspace } from '../../components/Workspace'; import { useWorkspace } from '../../components/Workspace';
import type { ProjectDefinition } from '../../components/Workspace'; import type { ProjectDefinition } from '../../components/Workspace';
import { useWorkspacesState } from 'state/useWorkspacesState'; import { useWorkspacesState } from 'state/useWorkspacesState';
@@ -12,7 +13,7 @@ type DevServerStatus = { running: boolean; url?: string; port?: number };
type PreviewProviderProps = { panelId: string; children: ReactNode }; type PreviewProviderProps = { panelId: string; children: ReactNode };
export const PreviewProvider = ({ children }: PreviewProviderProps) => { export const PreviewProvider = ({ panelId, children }: PreviewProviderProps) => {
const { cwd } = useWorkspace(); const { cwd } = useWorkspace();
const client = useClient(); const client = useClient();
const { user } = useAuth(); const { user } = useAuth();
@@ -58,10 +59,15 @@ export const PreviewProvider = ({ children }: PreviewProviderProps) => {
setStopped(true); setStopped(true);
}, [slug, client]); }, [slug, client]);
const restartServer = useCallback(() => { const restartServer = useCallback(async () => {
if (!slug) return; if (!slug) return;
try {
await client.post('/dev-server/stop', { slug });
} catch {
// ignore — server may not be running
}
startServer(slug); startServer(slug);
}, [slug, startServer]); }, [slug, client, startServer]);
const refresh = useCallback(() => setIframeKey((k) => k + 1), []); const refresh = useCallback(() => setIframeKey((k) => k + 1), []);
@@ -115,6 +121,12 @@ export const PreviewProvider = ({ children }: PreviewProviderProps) => {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [slug, url]); }, [slug, url]);
// Listen for preview:refresh signal from chat panel (triggers after tool-call turns)
const [refreshSignal] = usePanelChannel<number>('preview:refresh', 0);
useEffect(() => {
if (refreshSignal && slug && url) restartServer();
}, [refreshSignal]);
return ( return (
<PreviewContext <PreviewContext
value={{ value={{
@@ -19,10 +19,11 @@ type UsePiChatOptions = {
projectScoped?: boolean; projectScoped?: boolean;
context?: string; context?: string;
contextId?: string; contextId?: string;
onTurnComplete?: (hadToolCalls: boolean) => void;
}; };
export function usePiChat(initialSessionId?: string, initialModel?: string | null, options?: UsePiChatOptions) { export function usePiChat(initialSessionId?: string, initialModel?: string | null, options?: UsePiChatOptions) {
const { replaceUrl = true, storage, resourceChatDir, taskInfo, projectScoped, context, contextId } = options ?? {}; const { replaceUrl = true, storage, resourceChatDir, taskInfo, projectScoped, context, contextId, onTurnComplete } = options ?? {};
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [streamingText, setStreamingText] = useState(''); const [streamingText, setStreamingText] = useState('');
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
@@ -55,6 +56,9 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
const rafRef = useRef<number | null>(null); const rafRef = useRef<number | null>(null);
const sessionIdRef = useRef<string | null>(initialSessionId ?? null); const sessionIdRef = useRef<string | null>(initialSessionId ?? null);
const saveTimerRef = useRef<number | null>(null); const saveTimerRef = useRef<number | null>(null);
const toolCallsInTurnRef = useRef(false);
const onTurnCompleteRef = useRef(onTurnComplete);
onTurnCompleteRef.current = onTurnComplete;
const sessionFilter = context ? { context, contextId } : undefined; const sessionFilter = context ? { context, contextId } : undefined;
const { getSession, saveMessages, invalidate: invalidateSessions } = useChatSessions(sessionFilter); const { getSession, saveMessages, invalidate: invalidateSessions } = useChatSessions(sessionFilter);
@@ -104,6 +108,7 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
break; break;
case 'tool:start': case 'tool:start':
toolCallsInTurnRef.current = true;
setMessages((prev) => [ setMessages((prev) => [
...prev, ...prev,
{ {
@@ -125,7 +130,7 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
); );
break; break;
case 'result': case 'result': {
commitStreaming(); commitStreaming();
setMessages((prev) => [ setMessages((prev) => [
...prev, ...prev,
@@ -136,7 +141,11 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
]); ]);
setIsGenerating(false); setIsGenerating(false);
invalidateSessions(); invalidateSessions();
const hadTools = toolCallsInTurnRef.current;
toolCallsInTurnRef.current = false;
onTurnCompleteRef.current?.(hadTools);
break; break;
}
case 'sync:messages': case 'sync:messages':
sessionIdRef.current = msg.sessionId; sessionIdRef.current = msg.sessionId;
@@ -281,6 +290,7 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
setIsGenerating(true); setIsGenerating(true);
streamingRef.current = ''; streamingRef.current = '';
setStreamingText(''); setStreamingText('');
toolCallsInTurnRef.current = false;
// Parse dataUrls into { mediaType, data } for the server // Parse dataUrls into { mediaType, data } for the server
const imageData = images const imageData = images