diff --git a/.changeset/fix-planning-session-timers.md b/.changeset/fix-planning-session-timers.md new file mode 100644 index 0000000000..f2013c3887 --- /dev/null +++ b/.changeset/fix-planning-session-timers.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep planning timers session-specific and return cleanly from stopped generations. +category: fix +dev: Persists each generation's start time and restores the prior planning step when stopped. diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index ffdf950b59..5803f43584 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -811,16 +811,20 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat return; } - const startedAt = Date.now(); - setGenerationStartTime(startedAt); - setElapsedSeconds(0); + const startedAt = generationStartTime ?? Date.now(); + if (generationStartTime === null) { + setGenerationStartTime(startedAt); + } - const timer = setInterval(() => { + const updateElapsed = () => { setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000))); - }, 1000); + }; + updateElapsed(); + + const timer = setInterval(updateElapsed, 1000); return () => clearInterval(timer); - }, [view.type]); + }, [generationStartTime, view.type]); // Fallback for missed SSE 'question'/'summary' events: when the loading // state lingers, periodically refetch the session and transition the view @@ -1218,6 +1222,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setIsRetrying(!options.auto); setIsAutoRetrying(options.auto); setStreamingOutput(""); + setGenerationStartTime(Date.now()); setView({ type: "loading" }); currentSessionIdRef.current = retryTarget.sessionId; @@ -1366,6 +1371,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat refineSummaryInFlightRef.current = false; setGenerationActivity("initial_plan"); savePlanningDescription(startedPlan, projectId); + setGenerationStartTime(Date.now()); setView({ type: "loading" }); try { @@ -1499,6 +1505,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setIsRetrying(false); setIsRefiningSummary(false); refineSummaryInFlightRef.current = false; + setGenerationStartTime(null); setView({ type: "loading" }); try { @@ -1544,6 +1551,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat // An unavailable payload cannot provide a safe copy target. } setActivePlanPrompt(typeof inputPayload?.initialPlan === "string" ? inputPayload.initialPlan : ""); + const persistedGenerationStartedAt = typeof inputPayload?.generationStartedAt === "string" + ? Date.parse(inputPayload.generationStartedAt) + : Number.NaN; + setGenerationStartTime( + session.status === "generating" && Number.isFinite(persistedGenerationStartedAt) + ? persistedGenerationStartedAt + : null, + ); if (inputPayload?.generationPurpose === "plan_update" || inputPayload?.generationPurpose === "question" || inputPayload?.generationPurpose === "initial_plan") { setGenerationActivity(inputPayload.generationPurpose); } else if (session.status === "generating") { @@ -2250,6 +2265,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setConversationHistory(optimisticHistory); resetPlanningAutoRetryBudget(); setGenerationActivity("plan_update"); + setGenerationStartTime(Date.now()); setView({ type: "loading" }); setStreamingOutput(""); // Clear old thinking output when entering loading state liveGenerationSessionIdRef.current = sessionId; @@ -2341,6 +2357,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat if (!sessionId) { return; } + const priorQuestion = workspaceQuestion; + const summary = runningSummaryRef.current; + const history = conversationHistoryRef.current; try { await stopPlanningGeneration(sessionId, projectId); @@ -2354,13 +2373,31 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setIsAutoRetrying(false); setIsRefiningSummary(false); refineSummaryInFlightRef.current = false; - setView({ - type: "error", - session: { sessionId, currentQuestion: null, summary: null }, - errorMessage: t("planning.generationStopped", "Generation stopped by user. You can retry or start a new session."), - }); + setError(null); setStreamingOutput(""); - }, [projectId, t]); + setGenerationStartTime(null); + + if (priorQuestion) { + const answered = history.some((entry) => entry.question?.id === priorQuestion.id && entry.response); + setEditingQuestionId(answered ? priorQuestion.id : null); + setWorkspaceQuestion(priorQuestion); + setView({ + type: "question", + session: { sessionId, currentQuestion: priorQuestion, summary }, + }); + } else if (summary) { + setWorkspaceQuestion(null); + setView({ + type: "plan_review", + session: { sessionId, currentQuestion: null, summary }, + summary, + }); + } else { + draftSessionIdRef.current = sessionId; + setInitialPlan(_activePlanPrompt); + setView({ type: "initial" }); + } + }, [_activePlanPrompt, projectId, workspaceQuestion]); const handleRetryFromError = useCallback(async () => { if (view.type !== "error") { @@ -2383,6 +2420,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setError(null); setGenerationActivity("question"); setIsRefineMenuOpen(false); + setGenerationStartTime(Date.now()); setView({ type: "loading" }); try { const response = await respondToPlanning(sessionId, { refine: true, focus: refinementInstructions }, projectId); diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx index a9fda7b8c2..0e82c44087 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { PlanningModeModal } from "../PlanningModeModal"; -import { mockCreatePlanningDraft, mockFetchAiSession, mockFetchAiSessions, mockRespondToPlanning, mockStartPlanningStreaming, mockValidatePlanningSession, mockCreateTaskFromPlanning, mockTasks, mockSummary } from "./PlanningModeModal.test-helpers"; +import { mockCreatePlanningDraft, mockFetchAiSession, mockFetchAiSessions, mockRespondToPlanning, mockStartPlanningStreaming, mockStopPlanningGeneration, mockValidatePlanningSession, mockCreateTaskFromPlanning, mockTasks, mockSummary } from "./PlanningModeModal.test-helpers"; const mockViewportMode = vi.hoisted(() => vi.fn(() => "desktop" as "desktop" | "mobile")); const mockConnectPlanningStream = vi.hoisted(() => vi.fn()); @@ -24,7 +24,7 @@ vi.mock("../../api", () => { fetchAiSession: (...args: unknown[]) => mockFetchAiSession(...args), fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args), respondToPlanning: (...args: unknown[]) => mockRespondToPlanning(...args), validatePlanningSession: (...args: unknown[]) => mockValidatePlanningSession(...args), createTaskFromPlanning: (...args: unknown[]) => mockCreateTaskFromPlanning(...args), fetchSettings: fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }), fetchGlobalSettings: fn().mockResolvedValue({}), fetchModels: fn().mockResolvedValue([]), fetchWorkflowSteps: fn().mockResolvedValue([]), fetchBoardWorkflows: fn().mockResolvedValue({ workflows: [] }), - startPlanning: fn(), startPlanningStreaming: (...args: unknown[]) => mockStartPlanningStreaming(...args), createPlanningDraft: (...args: unknown[]) => mockCreatePlanningDraft(...args), connectPlanningStream: (...args: unknown[]) => mockConnectPlanningStream(...args), rewindPlanningSession: fn(), retryPlanningSession: fn(), cancelPlanning: fn(), stopPlanningGeneration: fn(), updatePlanningSessionDraft: fn(), updatePlanningSessionTitle: fn(), startPlanningBreakdown: fn(), createTasksFromPlanning: fn(), parseConversationHistory: (raw: string) => JSON.parse(raw || "[]"), acquireSessionLock: fn(), releaseSessionLock: fn(), forceAcquireSessionLock: fn(), uploadAttachment: fn(), deleteAttachment: fn(), updateTask: fn(), pauseTask: fn(), unpauseTask: fn(), fetchTaskDetail: fn(), requestSpecRevision: fn(), approvePlan: fn(), rejectPlan: fn(), refineTask: fn(), deleteAiSession: fn(), refineText: fn(), getRefineErrorMessage: (error: Error) => error.message, + startPlanning: fn(), startPlanningStreaming: (...args: unknown[]) => mockStartPlanningStreaming(...args), createPlanningDraft: (...args: unknown[]) => mockCreatePlanningDraft(...args), connectPlanningStream: (...args: unknown[]) => mockConnectPlanningStream(...args), rewindPlanningSession: fn(), retryPlanningSession: fn(), cancelPlanning: fn(), stopPlanningGeneration: (...args: unknown[]) => mockStopPlanningGeneration(...args), updatePlanningSessionDraft: fn(), updatePlanningSessionTitle: fn(), startPlanningBreakdown: fn(), createTasksFromPlanning: fn(), parseConversationHistory: (raw: string) => JSON.parse(raw || "[]"), acquireSessionLock: fn(), releaseSessionLock: fn(), forceAcquireSessionLock: fn(), uploadAttachment: fn(), deleteAttachment: fn(), updateTask: fn(), pauseTask: fn(), unpauseTask: fn(), fetchTaskDetail: fn(), requestSpecRevision: fn(), approvePlan: fn(), rejectPlan: fn(), refineTask: fn(), deleteAiSession: fn(), refineText: fn(), getRefineErrorMessage: (error: Error) => error.message, }; }); @@ -39,7 +39,7 @@ const summaryWithRefinements = { }; describe("PlanningModeModal sequential flow", () => { - beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); mockPlanningSse.events = null; mockViewportMode.mockReturnValue("desktop"); mockFetchAiSessions.mockResolvedValue([]); mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-1", title: "Secure plan" }); mockStartPlanningStreaming.mockResolvedValue({ sessionId: "draft-1" }); mockValidatePlanningSession.mockResolvedValue({ summary: mockSummary, validated: true }); mockCreateTaskFromPlanning.mockResolvedValue({ id: "FN-8442" }); }); + beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); mockPlanningSse.events = null; mockViewportMode.mockReturnValue("desktop"); mockFetchAiSessions.mockResolvedValue([]); mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-1", title: "Secure plan" }); mockStartPlanningStreaming.mockResolvedValue({ sessionId: "draft-1" }); mockStopPlanningGeneration.mockResolvedValue({ success: true }); mockValidatePlanningSession.mockResolvedValue({ summary: mockSummary, validated: true }); mockCreateTaskFromPlanning.mockResolvedValue({ id: "FN-8442" }); }); it("persists a draft before generation and immediately shows initial-plan progress", async () => { render(); fireEvent.change(screen.getByLabelText("What do you want to build?"), { target: { value: "Build secure accounts" } }); @@ -357,6 +357,49 @@ describe("PlanningModeModal sequential flow", () => { await waitFor(() => expect(mockConnectPlanningStream).toHaveBeenCalledTimes(1)); expect(mockConnectPlanningStream).toHaveBeenCalledWith("session-1", "project-1", expect.any(Object)); }); + it("keeps elapsed thinking time scoped to each generating session", async () => { + const now = Date.parse("2026-07-21T08:00:30.000Z"); + const dateNow = vi.spyOn(Date, "now").mockReturnValue(now); + mockFetchAiSession.mockImplementation(async (sessionId: string) => ({ + ...base, + id: sessionId, + status: "generating", + currentQuestion: null, + result: JSON.stringify(summaryWithRefinements), + inputPayload: JSON.stringify({ + generationPurpose: "plan_update", + generationStartedAt: new Date(now - (sessionId === "session-1" ? 25_000 : 7_000)).toISOString(), + }), + })); + const props = { isOpen: true, onClose: vi.fn(), onTaskCreated: vi.fn(), onTasksCreated: vi.fn(), tasks: mockTasks, projectId: "project-1" }; + const { rerender } = render(); + + expect(await screen.findByText("Thinking… (25s)")).toBeInTheDocument(); + + rerender(); + expect(await screen.findByText("Thinking… (7s)")).toBeInTheDocument(); + dateNow.mockRestore(); + }); + it("returns to the prior question without an error when generation is stopped", async () => { + const priorQuestion = { id: "q-prior", type: "text", question: "What should change?" }; + mockFetchAiSession.mockResolvedValue({ + ...base, + status: "generating", + currentQuestion: null, + result: JSON.stringify(summaryWithRefinements), + conversationHistory: JSON.stringify([{ question: priorQuestion, response: { "q-prior": "Preserve drafts" } }]), + inputPayload: JSON.stringify({ generationPurpose: "plan_update", generationStartedAt: new Date().toISOString() }), + }); + renderSession({}); + + await waitFor(() => expect(mockConnectPlanningStream).toHaveBeenCalledWith("session-1", "project-1", expect.any(Object))); + fireEvent.click(await screen.findByRole("button", { name: "Stop" })); + + await waitFor(() => expect(mockStopPlanningGeneration).toHaveBeenCalledWith("session-1", "project-1")); + expect(await screen.findByText("What should change?")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Type your answer here...")).toHaveValue("Preserve drafts"); + expect(screen.queryByText(/Generation stopped by user/i)).toBeNull(); + }); it("renders exactly one write-your-own choice for normalized select questions", async () => { mockFetchAiSession.mockResolvedValue({ ...base, diff --git a/packages/dashboard/src/__tests__/planning-generation-cancellation.test.ts b/packages/dashboard/src/__tests__/planning-generation-cancellation.test.ts index 8a6ff1547a..88bf313201 100644 --- a/packages/dashboard/src/__tests__/planning-generation-cancellation.test.ts +++ b/packages/dashboard/src/__tests__/planning-generation-cancellation.test.ts @@ -28,6 +28,7 @@ import { const MOCK_TASK_STORE = { listTasks: vi.fn(async () => []), + getSettings: vi.fn(async () => ({})), getTask: vi.fn(async () => { throw new Error("not found"); }), @@ -74,7 +75,7 @@ describe("planning generation cancellation", () => { expect(promptSignal?.aborted).toBe(true); expect(dispose).toHaveBeenCalledTimes(1); - expect((await getSession(sessionId))?.error).toMatch(/stopped by user/i); + expect((await getSession(sessionId))?.error).toBeUndefined(); resolveHungPrompt?.(); await new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index 18cebd603e..e498672902 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -304,6 +304,8 @@ export const DRAFT_PLACEHOLDER_TITLE = "New planning session"; export interface DraftInputPayload { initialPlan?: string; generationPurpose?: "initial_plan" | "plan_update" | "question"; + generationStartedAt?: string; + generationReturnQuestion?: PlanningQuestion; clarificationEnabled?: boolean; lastMailboxNotifiedQuestionKey?: string; modelProvider?: string; @@ -341,7 +343,6 @@ export const GENERATION_LOOP_REPEAT_LIMIT = 8; const PLANNING_STUCK_ERROR_MESSAGE = "AI generation appears stuck with no new output. You can retry or start a new session."; const PLANNING_LOOP_ERROR_MESSAGE = "AI generation appears stuck repeating the same output. You can retry or start a new session."; -const PLANNING_USER_STOP_ERROR_MESSAGE = "Generation stopped by user. You can retry or start a new session."; // ── Types ─────────────────────────────────────────────────────────────────── @@ -401,6 +402,10 @@ interface Session { claimStartedAt?: string; /** Whether the current generation must end at plan review rather than a question. */ generationPurpose?: "initial_plan" | "plan_update" | "question"; + /** Durable start time for the active turn so each concurrent session owns its elapsed clock. */ + generationStartedAt?: string; + /** Question restored when the user stops the active turn. */ + generationReturnQuestion?: PlanningQuestion; /** Last terminal error for retry UX */ error?: string; /** AI agent session for real-time interaction */ @@ -612,6 +617,8 @@ function persistSession(session: Session, status: "generating" | "awaiting_input ? { clarificationEnabled: session.clarificationEnabled } : {}), ...(session.generationPurpose ? { generationPurpose: session.generationPurpose } : {}), + ...(session.generationStartedAt ? { generationStartedAt: session.generationStartedAt } : {}), + ...(session.generationReturnQuestion ? { generationReturnQuestion: session.generationReturnQuestion } : {}), ...(session.lastMailboxNotifiedQuestionKey ? { lastMailboxNotifiedQuestionKey: session.lastMailboxNotifiedQuestionKey } : {}), }), conversationHistory: JSON.stringify(session.history), @@ -676,6 +683,21 @@ function unpersistSession(sessionId: string): Promise { return queued; } +/* +FNXC:PlanningMode 2026-07-21-00:25: +Each active planning turn owns a durable clock and return point. A modal-level Date.now() +clock makes concurrent sessions appear synchronized, while clearing the question before +generation leaves Stop with nowhere safe to return. Persist both at the turn boundary. +*/ +function beginPlanningGeneration( + session: Session, + purpose: NonNullable, +): void { + session.generationPurpose = purpose; + session.generationStartedAt = new Date().toISOString(); + session.generationReturnQuestion = session.currentQuestion ?? session.generationReturnQuestion; +} + /** Release in-memory planning runtime state while keeping persisted history. */ export function releaseSession(sessionId: string): void { cleanupInMemorySession(sessionId); @@ -752,6 +774,12 @@ function buildSessionFromRow(row: AiSessionRow): Session { || payload.generationPurpose === "question" ? payload.generationPurpose : undefined, + generationStartedAt: typeof payload.generationStartedAt === "string" + ? payload.generationStartedAt + : undefined, + generationReturnQuestion: payload.generationReturnQuestion && typeof payload.generationReturnQuestion === "object" + ? normalizePlanningQuestion(payload.generationReturnQuestion, payload.initialPlan ?? row.title) + : undefined, lastMailboxNotifiedQuestionKey: typeof payload.lastMailboxNotifiedQuestionKey === "string" ? payload.lastMailboxNotifiedQuestionKey : undefined, @@ -1083,6 +1111,7 @@ export async function createSession( }; sessions.set(sessionId, session); + beginPlanningGeneration(session, "initial_plan"); persistSession(session, "generating"); const systemPrompt = await resolvePlanningModeSystemPrompt(store, promptOverrides, session.workflowId); @@ -1588,7 +1617,7 @@ export async function startExistingSession( session.ntfyConfig = runtimeOptions.ntfyConfig; session.messageStore = runtimeOptions.messageStore; } - session.generationPurpose = "initial_plan"; + beginPlanningGeneration(session, "initial_plan"); await persistSession(session, "generating"); planningStreamManager.registerInitialTurn(sessionId, () => { session.pluginRunner = pluginRunner; @@ -1672,7 +1701,7 @@ export async function createSessionWithAgent( }; sessions.set(sessionId, session); - session.generationPurpose = "initial_plan"; + beginPlanningGeneration(session, "initial_plan"); await persistSession(session, "generating"); planningStreamManager.registerInitialTurn(sessionId, () => { @@ -2104,14 +2133,6 @@ async function runGenerationWithTimeout(session: Session, operation: (abortSi try { return await Promise.race([operation(abortController.signal), abortPromise]); - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - const reason = generationRecord.abortReason; - if (reason === "user-stop" && !session.error) { - setSessionError(session, PLANNING_USER_STOP_ERROR_MESSAGE); - } - } - throw error; } finally { clearTimeout(generationRecord.timer); if (activeGenerations.get(session.id) === generationRecord) { @@ -2480,6 +2501,8 @@ async function continueAgentConversation(session: Session, message: string): Pro session.lastGeneratedThinking = session.thinkingOutput; session.updatedAt = new Date(); session.generationPurpose = undefined; + session.generationStartedAt = undefined; + session.generationReturnQuestion = undefined; session.currentQuestion = coerceQuestionResponse(parsed, session); await persistSession(session, "awaiting_input"); planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary }); @@ -2835,8 +2858,8 @@ export async function submitResponse( if (isRefineRequest(responses) && session.summary) { // Refinement steers which question comes next; it is never an answer to the // currently displayed question and therefore must not create a history entry. + beginPlanningGeneration(session, "question"); session.currentQuestion = undefined; - session.generationPurpose = "question"; session.error = undefined; await persistSession(session, "generating"); @@ -2877,8 +2900,8 @@ export async function submitResponse( // Clear the answered question while generation is active so reconnects cannot replay it. // The completed turn persists and broadcasts exactly one newly generated question. + beginPlanningGeneration(session, "plan_update"); session.currentQuestion = undefined; - session.generationPurpose = "plan_update"; await persistSession(session, "generating"); if (!session.agent) { // An edited older answer must be replayed in its original position with every @@ -2962,7 +2985,7 @@ export async function retrySession( */ session.currentQuestion = undefined; session.updatedAt = new Date(); - session.generationPurpose = session.history.length === 0 ? "initial_plan" : "plan_update"; + beginPlanningGeneration(session, session.history.length === 0 ? "initial_plan" : "plan_update"); await persistSession(session, "generating"); if (session.history.length === 0) { @@ -3058,7 +3081,32 @@ export function stopGeneration(sessionId: string): boolean { activeGeneration.abortController.abort(); activeGenerations.delete(sessionId); - setSessionError(session, PLANNING_USER_STOP_ERROR_MESSAGE); + const returnQuestion = session.generationReturnQuestion; + const stoppedPurpose = session.generationPurpose; + session.error = undefined; + session.thinkingOutput = ""; + session.generationPurpose = undefined; + session.generationStartedAt = undefined; + session.generationReturnQuestion = undefined; + session.updatedAt = new Date(); + + if (returnQuestion) { + session.currentQuestion = returnQuestion; + session.editingQuestionId = session.history.some((entry) => entry.question.id === returnQuestion.id) + ? returnQuestion.id + : undefined; + session.summary = buildRunningSummary(session.initialPlan, session.history, session.summary); + void persistSession(session, "awaiting_input"); + planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary }); + planningStreamManager.broadcast(session.id, { type: "question", data: returnQuestion }); + } else { + session.currentQuestion = undefined; + session.editingQuestionId = undefined; + void persistSession(session, stoppedPurpose === "initial_plan" ? "draft" : "awaiting_input"); + if (session.summary && stoppedPurpose !== "initial_plan") { + planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary }); + } + } return true; }