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 () => {
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');
+7 -2
View File
@@ -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<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);
});
+3 -2
View File
@@ -21,6 +21,7 @@ export async function enqueue(params: EnqueueParams): Promise<Job> {
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);
}
}
+8 -5
View File
@@ -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}`);
}
+2
View File
@@ -30,6 +30,7 @@ export type Job = {
startedAt?: number;
completedAt?: number;
meta?: Record<string, unknown>;
notify?: boolean;
};
export type StepContext = {
@@ -54,4 +55,5 @@ export type EnqueueParams = {
type: string;
userId: string;
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 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),
onSuccess: invalidate,
});
@@ -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);
};
@@ -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<string, string | undefined>;
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<ChatSessionSelection | null>('chat:panel-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 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 (
<ChatPanelInner
@@ -75,6 +83,7 @@ export const ChatPanelWrapper = () => {
promptPrefix={promptPrefix}
chatContext={chatContext}
setActiveSession={setActiveSession}
onTurnComplete={onTurnComplete}
/>
);
};
@@ -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 = () => {
<>
<button
type="button"
onClick={refresh}
onClick={restartServer}
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0"
title="Refresh"
>
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import type { ReactNode } from 'react';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useWorkspace } from '../../components/Workspace';
import type { ProjectDefinition } from '../../components/Workspace';
import { useWorkspacesState } from 'state/useWorkspacesState';
@@ -12,7 +13,7 @@ type DevServerStatus = { running: boolean; url?: string; port?: number };
type PreviewProviderProps = { panelId: string; children: ReactNode };
export const PreviewProvider = ({ children }: PreviewProviderProps) => {
export const PreviewProvider = ({ panelId, children }: PreviewProviderProps) => {
const { cwd } = useWorkspace();
const client = useClient();
const { user } = useAuth();
@@ -58,10 +59,15 @@ export const PreviewProvider = ({ children }: PreviewProviderProps) => {
setStopped(true);
}, [slug, client]);
const restartServer = useCallback(() => {
const restartServer = useCallback(async () => {
if (!slug) return;
try {
await client.post('/dev-server/stop', { slug });
} catch {
// ignore — server may not be running
}
startServer(slug);
}, [slug, startServer]);
}, [slug, client, startServer]);
const refresh = useCallback(() => setIframeKey((k) => k + 1), []);
@@ -115,6 +121,12 @@ export const PreviewProvider = ({ children }: PreviewProviderProps) => {
return () => clearInterval(interval);
}, [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 (
<PreviewContext
value={{
@@ -19,10 +19,11 @@ type UsePiChatOptions = {
projectScoped?: boolean;
context?: string;
contextId?: string;
onTurnComplete?: (hadToolCalls: boolean) => void;
};
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 [streamingText, setStreamingText] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
@@ -55,6 +56,9 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
const rafRef = useRef<number | null>(null);
const sessionIdRef = useRef<string | null>(initialSessionId ?? 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 { getSession, saveMessages, invalidate: invalidateSessions } = useChatSessions(sessionFilter);
@@ -104,6 +108,7 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
break;
case 'tool:start':
toolCallsInTurnRef.current = true;
setMessages((prev) => [
...prev,
{
@@ -125,7 +130,7 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
);
break;
case 'result':
case 'result': {
commitStreaming();
setMessages((prev) => [
...prev,
@@ -136,7 +141,11 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
]);
setIsGenerating(false);
invalidateSessions();
const hadTools = toolCallsInTurnRef.current;
toolCallsInTurnRef.current = false;
onTurnCompleteRef.current?.(hadTools);
break;
}
case 'sync:messages':
sessionIdRef.current = msg.sessionId;
@@ -281,6 +290,7 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
setIsGenerating(true);
streamingRef.current = '';
setStreamingText('');
toolCallsInTurnRef.current = false;
// Parse dataUrls into { mediaType, data } for the server
const imageData = images