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 [isCreatingTask, setIsCreatingTask] = useState(false);
|
||||||
const [isStartingBreakdown, setIsStartingBreakdown] = useState(false);
|
const [isStartingBreakdown, setIsStartingBreakdown] = useState(false);
|
||||||
const [isCreatingFromBreakdown, setIsCreatingFromBreakdown] = 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 [isRefiningSummary, setIsRefiningSummary] = useState(false);
|
||||||
const [generationStartTime, setGenerationStartTime] = useState<number | null>(null);
|
const [generationStartTime, setGenerationStartTime] = useState<number | null>(null);
|
||||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
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]);
|
}, [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 () => {
|
const handleBack = useCallback(async () => {
|
||||||
if (view.type !== "question" || responseHistory.length === 0) {
|
if (view.type !== "question" || responseHistory.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -1964,7 +1984,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
|
|
||||||
const sessionId = view.session.sessionId;
|
const sessionId = view.session.sessionId;
|
||||||
setError(null);
|
setError(null);
|
||||||
setView({ type: "loading" });
|
setIsBackPending(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rewound = await rewindPlanningSession(sessionId, projectId, sessionTabId);
|
const rewound = await rewindPlanningSession(sessionId, projectId, sessionTabId);
|
||||||
@@ -1994,6 +2014,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(getErrorMessage(err) || t("planning.failedGoBack", "Failed to go back to the previous question"));
|
setError(getErrorMessage(err) || t("planning.failedGoBack", "Failed to go back to the previous question"));
|
||||||
setView({ type: "question", session: view.session });
|
setView({ type: "question", session: view.session });
|
||||||
|
} finally {
|
||||||
|
setIsBackPending(false);
|
||||||
}
|
}
|
||||||
}, [projectId, responseHistory.length, sessionTabId, view]);
|
}, [projectId, responseHistory.length, sessionTabId, view]);
|
||||||
|
|
||||||
@@ -2391,6 +2413,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
historyEntries={conversationHistory}
|
historyEntries={conversationHistory}
|
||||||
onSubmit={handleSubmitResponse}
|
onSubmit={handleSubmitResponse}
|
||||||
onBack={responseHistory.length > 0 ? handleBack : undefined}
|
onBack={responseHistory.length > 0 ? handleBack : undefined}
|
||||||
|
isBackPending={isBackPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2478,9 +2501,10 @@ interface QuestionFormProps {
|
|||||||
historyEntries: ConversationHistoryEntry[];
|
historyEntries: ConversationHistoryEntry[];
|
||||||
onSubmit: (responses: QuestionResponse) => void;
|
onSubmit: (responses: QuestionResponse) => void;
|
||||||
onBack?: () => 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 { t } = useTranslation("app");
|
||||||
const question = normalizeQuestionOptions(rawQuestion);
|
const question = normalizeQuestionOptions(rawQuestion);
|
||||||
const questionOptions = question.options ?? [];
|
const questionOptions = question.options ?? [];
|
||||||
@@ -2831,15 +2855,15 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
|
|||||||
|
|
||||||
<div className="planning-actions">
|
<div className="planning-actions">
|
||||||
{onBack && (
|
{onBack && (
|
||||||
<button className="btn" onClick={onBack}>
|
<button className="btn" onClick={onBack} disabled={isBackPending}>
|
||||||
<ArrowLeft size={16} className="icon-mr-4" />
|
{isBackPending ? <Loader2 size={16} className="icon-mr-4 spin" /> : <ArrowLeft size={16} className="icon-mr-4" />}
|
||||||
{t("common.back", "Back")}
|
{t("common.back", "Back")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary planning-actions-primary"
|
className="btn btn-primary planning-actions-primary"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!isValid()}
|
disabled={!isValid() || isBackPending}
|
||||||
>
|
>
|
||||||
{t("planning.continue", "Continue")}
|
{t("planning.continue", "Continue")}
|
||||||
<ArrowRight size={16} className="icon-ml-4" />
|
<ArrowRight size={16} className="icon-ml-4" />
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
PLANNING_DEEPEN_CHECKPOINT_QUESTION,
|
PLANNING_DEEPEN_CHECKPOINT_QUESTION,
|
||||||
PLANNING_DEEPEN_PROCEED_OPTION_ID,
|
PLANNING_DEEPEN_PROCEED_OPTION_ID,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import type { MergeResult } from "@fusion/core";
|
import type { MergeResult, PlanningQuestion } from "@fusion/core";
|
||||||
const mockUseAiSessionSync = vi.fn();
|
const mockUseAiSessionSync = vi.fn();
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
mockCreatePlanningDraft,
|
mockCreatePlanningDraft,
|
||||||
mockConnectPlanningStream,
|
mockConnectPlanningStream,
|
||||||
mockRespondToPlanning,
|
mockRespondToPlanning,
|
||||||
|
mockRewindPlanningSession,
|
||||||
mockRetryPlanningSession,
|
mockRetryPlanningSession,
|
||||||
mockCancelPlanning,
|
mockCancelPlanning,
|
||||||
mockStopPlanningGeneration,
|
mockStopPlanningGeneration,
|
||||||
@@ -81,6 +82,7 @@ vi.mock("../../api", () => ({
|
|||||||
createPlanningDraft: (...args: any[]) => mockCreatePlanningDraft(...args),
|
createPlanningDraft: (...args: any[]) => mockCreatePlanningDraft(...args),
|
||||||
connectPlanningStream: (...args: any[]) => mockConnectPlanningStream(...args),
|
connectPlanningStream: (...args: any[]) => mockConnectPlanningStream(...args),
|
||||||
respondToPlanning: (...args: any[]) => mockRespondToPlanning(...args),
|
respondToPlanning: (...args: any[]) => mockRespondToPlanning(...args),
|
||||||
|
rewindPlanningSession: (...args: any[]) => mockRewindPlanningSession(...args),
|
||||||
retryPlanningSession: (...args: any[]) => mockRetryPlanningSession(...args),
|
retryPlanningSession: (...args: any[]) => mockRetryPlanningSession(...args),
|
||||||
cancelPlanning: (...args: any[]) => mockCancelPlanning(...args),
|
cancelPlanning: (...args: any[]) => mockCancelPlanning(...args),
|
||||||
stopPlanningGeneration: (...args: any[]) => mockStopPlanningGeneration(...args),
|
stopPlanningGeneration: (...args: any[]) => mockStopPlanningGeneration(...args),
|
||||||
@@ -181,6 +183,7 @@ describe("PlanningModeModal", () => {
|
|||||||
mockReleaseSessionLock.mockResolvedValue(undefined);
|
mockReleaseSessionLock.mockResolvedValue(undefined);
|
||||||
mockForceAcquireSessionLock.mockResolvedValue(undefined);
|
mockForceAcquireSessionLock.mockResolvedValue(undefined);
|
||||||
mockCancelPlanning.mockResolvedValue(undefined);
|
mockCancelPlanning.mockResolvedValue(undefined);
|
||||||
|
mockRewindPlanningSession.mockReset();
|
||||||
mockUpdatePlanningSessionDraft.mockResolvedValue({ ok: true });
|
mockUpdatePlanningSessionDraft.mockResolvedValue({ ok: true });
|
||||||
mockStopPlanningGeneration.mockResolvedValue({ success: true });
|
mockStopPlanningGeneration.mockResolvedValue({ success: true });
|
||||||
mockUseAiSessionSync.mockReturnValue({
|
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", () => {
|
describe("Session history", () => {
|
||||||
it("renders only one row when fetch and SSE deliver the same session id", async () => {
|
it("renders only one row when fetch and SSE deliver the same session id", async () => {
|
||||||
mockFetchAiSessions.mockResolvedValueOnce([
|
mockFetchAiSessions.mockResolvedValueOnce([
|
||||||
|
|||||||
Reference in New Issue
Block a user