FN-7615: fix Planning Mode Back button flashing generation screen
Fix Planning Mode's Back button incorrectly rendering the AI generation/loading view instead of returning directly to the previous question. - handleBack no longer sets view to "loading" during the deterministic rewindPlanningSession call; that view is reserved for real model-generation turns. - Added isBackPending state to drive a lightweight inline pending indicator on the Back button (spinner icon, disabled Back/Continue) while the rewind request is in flight, keeping the QuestionForm mounted throughout. - On success, the rewound history/question is applied the same as before; on failure, the error message is surfaced while remaining on the question view (never loading). - Added a changeset for the fix. - Expanded PlanningModeModal.planning-flow.test.tsx coverage for the Back button's success/error/pending behavior. Files changed: .changeset/FN-7615-planning-back-no-generation.md | 7 + .../dashboard/app/components/PlanningModeModal.tsx | 34 +++- .../PlanningModeModal.planning-flow.test.tsx | 182 ++++++++++++++++++++- 3 files changed, 217 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-7615 Fusion-Task-Lineage: 27e9c541-ce2f-42de-b83a-777af1e56858 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/FN-7615-planning-back-no-generation.md
Normal file
7
.changeset/FN-7615-planning-back-no-generation.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix Planning Mode Back button showing a generation screen instead of the previous question.
|
||||
category: fix
|
||||
dev: handleBack in PlanningModeModal no longer transitions to the loading view during the deterministic rewindPlanningSession; Back returns directly to the previous question form (success and error paths) and never renders .planning-loading.
|
||||
@@ -322,6 +322,15 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
const [isCreatingTask, setIsCreatingTask] = useState(false);
|
||||
const [isStartingBreakdown, setIsStartingBreakdown] = useState(false);
|
||||
const [isCreatingFromBreakdown, setIsCreatingFromBreakdown] = useState(false);
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-05-00:00:
|
||||
FN-7615: Back is deterministic history navigation (a pure server-side rewind that pops the last
|
||||
history entry), not AI generation. isBackPending drives a lightweight inline pending state on the
|
||||
Back button itself so the QuestionForm stays mounted throughout — it must never trigger the
|
||||
`.planning-loading` generation view (spinner + "Generating next question..."), which is reserved
|
||||
for real model-generation turns.
|
||||
*/
|
||||
const [isBackPending, setIsBackPending] = useState(false);
|
||||
const [isRefiningSummary, setIsRefiningSummary] = useState(false);
|
||||
const [generationStartTime, setGenerationStartTime] = useState<number | null>(null);
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
@@ -1957,6 +1966,17 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
}
|
||||
}, [baseBranch, branchMode, branchName, broadcastCompleted, handleClose, view, onTasksCreated, projectId, workflowId]);
|
||||
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-05-00:00:
|
||||
FN-7615: Back must never render `.planning-loading`. rewindSession (src/planning.ts) is a
|
||||
deterministic history pop + `question` SSE broadcast — it performs no model call — so treating it
|
||||
like a generation turn (setView({type:"loading"})) was a bug: the user perceives Back as "not
|
||||
working" because it flashes a spinner/"Generating next question..." screen for an instant,
|
||||
synchronous-feeling navigation. Stay on the question view throughout; use isBackPending only to
|
||||
disable the Back/submit controls while the request is in flight. On success, apply the
|
||||
authoritative rewound history/question (same mapping as before). On failure, surface
|
||||
planning.failedGoBack and remain on the question view — never loading.
|
||||
*/
|
||||
const handleBack = useCallback(async () => {
|
||||
if (view.type !== "question" || responseHistory.length === 0) {
|
||||
return;
|
||||
@@ -1964,7 +1984,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
|
||||
const sessionId = view.session.sessionId;
|
||||
setError(null);
|
||||
setView({ type: "loading" });
|
||||
setIsBackPending(true);
|
||||
|
||||
try {
|
||||
const rewound = await rewindPlanningSession(sessionId, projectId, sessionTabId);
|
||||
@@ -1994,6 +2014,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err) || t("planning.failedGoBack", "Failed to go back to the previous question"));
|
||||
setView({ type: "question", session: view.session });
|
||||
} finally {
|
||||
setIsBackPending(false);
|
||||
}
|
||||
}, [projectId, responseHistory.length, sessionTabId, view]);
|
||||
|
||||
@@ -2391,6 +2413,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
historyEntries={conversationHistory}
|
||||
onSubmit={handleSubmitResponse}
|
||||
onBack={responseHistory.length > 0 ? handleBack : undefined}
|
||||
isBackPending={isBackPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -2478,9 +2501,10 @@ interface QuestionFormProps {
|
||||
historyEntries: ConversationHistoryEntry[];
|
||||
onSubmit: (responses: QuestionResponse) => void;
|
||||
onBack?: () => void;
|
||||
isBackPending?: boolean;
|
||||
}
|
||||
|
||||
function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmit, onBack }: QuestionFormProps) {
|
||||
function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmit, onBack, isBackPending = false }: QuestionFormProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const question = normalizeQuestionOptions(rawQuestion);
|
||||
const questionOptions = question.options ?? [];
|
||||
@@ -2831,15 +2855,15 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
|
||||
|
||||
<div className="planning-actions">
|
||||
{onBack && (
|
||||
<button className="btn" onClick={onBack}>
|
||||
<ArrowLeft size={16} className="icon-mr-4" />
|
||||
<button className="btn" onClick={onBack} disabled={isBackPending}>
|
||||
{isBackPending ? <Loader2 size={16} className="icon-mr-4 spin" /> : <ArrowLeft size={16} className="icon-mr-4" />}
|
||||
{t("common.back", "Back")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-primary planning-actions-primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={!isValid()}
|
||||
disabled={!isValid() || isBackPending}
|
||||
>
|
||||
{t("planning.continue", "Continue")}
|
||||
<ArrowRight size={16} className="icon-ml-4" />
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
PLANNING_DEEPEN_CHECKPOINT_QUESTION,
|
||||
PLANNING_DEEPEN_PROCEED_OPTION_ID,
|
||||
} from "@fusion/core";
|
||||
import type { MergeResult } from "@fusion/core";
|
||||
import type { MergeResult, PlanningQuestion } from "@fusion/core";
|
||||
const mockUseAiSessionSync = vi.fn();
|
||||
|
||||
import {
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
mockCreatePlanningDraft,
|
||||
mockConnectPlanningStream,
|
||||
mockRespondToPlanning,
|
||||
mockRewindPlanningSession,
|
||||
mockRetryPlanningSession,
|
||||
mockCancelPlanning,
|
||||
mockStopPlanningGeneration,
|
||||
@@ -81,6 +82,7 @@ vi.mock("../../api", () => ({
|
||||
createPlanningDraft: (...args: any[]) => mockCreatePlanningDraft(...args),
|
||||
connectPlanningStream: (...args: any[]) => mockConnectPlanningStream(...args),
|
||||
respondToPlanning: (...args: any[]) => mockRespondToPlanning(...args),
|
||||
rewindPlanningSession: (...args: any[]) => mockRewindPlanningSession(...args),
|
||||
retryPlanningSession: (...args: any[]) => mockRetryPlanningSession(...args),
|
||||
cancelPlanning: (...args: any[]) => mockCancelPlanning(...args),
|
||||
stopPlanningGeneration: (...args: any[]) => mockStopPlanningGeneration(...args),
|
||||
@@ -181,6 +183,7 @@ describe("PlanningModeModal", () => {
|
||||
mockReleaseSessionLock.mockResolvedValue(undefined);
|
||||
mockForceAcquireSessionLock.mockResolvedValue(undefined);
|
||||
mockCancelPlanning.mockResolvedValue(undefined);
|
||||
mockRewindPlanningSession.mockReset();
|
||||
mockUpdatePlanningSessionDraft.mockResolvedValue({ ok: true });
|
||||
mockStopPlanningGeneration.mockResolvedValue({ success: true });
|
||||
mockUseAiSessionSync.mockReturnValue({
|
||||
@@ -3459,6 +3462,183 @@ describe("PlanningModeModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-05-00:00:
|
||||
FN-7615 regression coverage: Back is deterministic history navigation (a pure server-side
|
||||
rewind), not AI generation, so it must never render `.planning-loading` (the "Generating next
|
||||
question..."/"AI is thinking..." spinner + Stop screen reserved for real model turns). Cover the
|
||||
success path, the failure path (error surfaced, still on a question form), and the
|
||||
no-history-yet state where the Back button is absent.
|
||||
*/
|
||||
describe("Back navigation (FN-7615)", () => {
|
||||
const secondQuestion: PlanningQuestion = {
|
||||
id: "q-requirements",
|
||||
type: "text",
|
||||
question: "What are the key requirements?",
|
||||
description: "Describe the requirements",
|
||||
};
|
||||
|
||||
const thirdQuestion: PlanningQuestion = {
|
||||
id: "q-details",
|
||||
type: "text",
|
||||
question: "Any additional details?",
|
||||
description: "Optional extra context",
|
||||
};
|
||||
|
||||
async function advanceToThirdQuestion() {
|
||||
let streamHandlers: any;
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
streamHandlers = handlers;
|
||||
setTimeout(() => {
|
||||
handlers.onQuestion?.(mockQuestion);
|
||||
}, 10);
|
||||
|
||||
return {
|
||||
close: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
let respondCallCount = 0;
|
||||
mockRespondToPlanning.mockImplementation(async () => {
|
||||
respondCallCount += 1;
|
||||
const nextQuestion = respondCallCount === 1 ? secondQuestion : thirdQuestion;
|
||||
setTimeout(() => {
|
||||
streamHandlers?.onQuestion?.(nextQuestion);
|
||||
}, 10);
|
||||
return { sessionId: "session-123", currentQuestion: null, summary: null };
|
||||
});
|
||||
|
||||
const renderResult = render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
|
||||
target: { value: "Build auth system" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Planning"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("What is the scope?")).toBeDefined();
|
||||
});
|
||||
|
||||
const mediumOption = await screen.findByText("Medium");
|
||||
fireEvent.click(mediumOption);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Continue" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("What are the key requirements?")).toBeDefined();
|
||||
}, { timeout: 5000 });
|
||||
|
||||
const requirementsTextarea = screen.getByPlaceholderText("Type your answer here...");
|
||||
fireEvent.change(requirementsTextarea, { target: { value: "Auth requirements" } });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Continue" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Any additional details?")).toBeDefined();
|
||||
}, { timeout: 5000 });
|
||||
|
||||
return renderResult;
|
||||
}
|
||||
|
||||
it("never renders the generation screen while going back, and restores the previous question with prior Q&A visible", async () => {
|
||||
let resolveRewind!: (value: {
|
||||
currentQuestion: PlanningQuestion;
|
||||
history: Array<{ question: PlanningQuestion; response: unknown; thinkingOutput?: string }>;
|
||||
}) => void;
|
||||
mockRewindPlanningSession.mockImplementation(
|
||||
() => new Promise((resolve) => {
|
||||
resolveRewind = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = await advanceToThirdQuestion();
|
||||
|
||||
const backButton = screen.getByRole("button", { name: /Back/i });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(backButton);
|
||||
});
|
||||
|
||||
// Symptom assertion (FN-7615): immediately after the click, while the deterministic
|
||||
// rewind is still in flight, the generation view must never be present.
|
||||
expect(container.querySelector(".planning-loading")).toBeNull();
|
||||
expect(screen.queryByText("Generating next question...")).toBeNull();
|
||||
expect(screen.queryByText("AI is thinking...")).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
resolveRewind({
|
||||
currentQuestion: secondQuestion,
|
||||
history: [{ question: mockQuestion, response: { [mockQuestion.id]: "medium" } }],
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "What are the key requirements?" })).toBeDefined();
|
||||
});
|
||||
|
||||
// Symptom assertion (FN-7615): after the async rewind settles, the generation view must
|
||||
// still never have appeared, and the previous question form is shown with the prior Q&A
|
||||
// (Q1's restored answer) visible above it.
|
||||
expect(container.querySelector(".planning-loading")).toBeNull();
|
||||
expect(screen.getByTestId("conversation-history")).toBeDefined();
|
||||
expect(screen.getByText("What is the scope?")).toBeDefined();
|
||||
expect(screen.getByText("Medium")).toBeDefined();
|
||||
expect(mockRewindPlanningSession).toHaveBeenCalledWith("session-123", undefined, expect.any(String));
|
||||
});
|
||||
|
||||
it("stays on the question form and surfaces an error when the rewind request fails", async () => {
|
||||
mockRewindPlanningSession.mockRejectedValueOnce(new Error("rewind failed"));
|
||||
|
||||
const { container } = await advanceToThirdQuestion();
|
||||
|
||||
const backButton = screen.getByRole("button", { name: /Back/i });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(backButton);
|
||||
});
|
||||
|
||||
expect(container.querySelector(".planning-loading")).toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("rewind failed")).toBeDefined();
|
||||
});
|
||||
|
||||
// Still on a question form (not loading, not generation) after the failure.
|
||||
expect(container.querySelector(".planning-loading")).toBeNull();
|
||||
expect(screen.getByText("Any additional details?")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render a Back button on the first question, before any history exists", async () => {
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
|
||||
target: { value: "Build auth system" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Planning"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("What is the scope?")).toBeDefined();
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("button", { name: /Back/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Session history", () => {
|
||||
it("renders only one row when fetch and SSE deliver the same session id", async () => {
|
||||
mockFetchAiSessions.mockResolvedValueOnce([
|
||||
|
||||
Reference in New Issue
Block a user