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:
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user