diff --git a/.changeset/fn-8757-mobile-planning-review.md b/.changeset/fn-8757-mobile-planning-review.md new file mode 100644 index 0000000000..de2c7c78df --- /dev/null +++ b/.changeset/fn-8757-mobile-planning-review.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Add a mobile Planning Mode shortcut to review the evolving plan after five answers. +category: feature +dev: Review plan switches the existing mobile workspace tab without submitting the current response. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 810c36a62b..219e8542d2 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -600,6 +600,9 @@ Planning Mode uses a dedicated collaborative system prompt. It investigates rele Planning Mode is a single-surface sequence: enter an idea, wait while Fusion generates a concrete initial plan, then review that evolving work product before deciding whether clarification is needed. Plan review shows the title, description, explicit **What to change** and **Acceptance criteria** sections, and deliverables, plus model-suggested **Focus the next question** choices and **Write your own focus**. Choose **Refine** to ask one high-impact question or **Validate** to accept the current plan. Answering a question shows **Updating plan…** and returns to plan review rather than automatically starting another question. The active generation purpose and running plan are persisted, so refreshing or navigating away during generation restores the correct progress state and eventual review. + +After five completed answers, mobile interviews show **Next question** and **Review plan**. **Review plan** selects the existing **Plan preview** tab only: it does not submit the current response or start generation. Select **Questions** at any time to return to the same question and unsent answer. Desktop and earlier mobile turns retain the single **Next** action. + Choose **Validate** when the plan is ready. Validation is durable and immediately creates the task using the selected workflow and branch settings. If creation is interrupted after validation, Planning restores a create-only retry state; it never validates again or creates a second task. **Sessions** (and mobile Back) remains the escape hatch for browsing, switching, and reviewing session history, with **New session** pinned in the saved-session list. The AI never ends an interview on its own; selection questions still provide alternatives with pros and cons plus an **Other** free-text choice. diff --git a/docs/solutions/test-failures/suite-only-flakes-observed-register.md b/docs/solutions/test-failures/suite-only-flakes-observed-register.md index a63e6cb34b..867470daf3 100644 --- a/docs/solutions/test-failures/suite-only-flakes-observed-register.md +++ b/docs/solutions/test-failures/suite-only-flakes-observed-register.md @@ -61,6 +61,20 @@ This register preserves first-sighting evidence under the narrow exception in [A Seven tests failed in `plugin-runner.test.ts`, but only this one identity survived capture: `--reporter=dot | tail -3` truncated the `FAIL` lines and retained only the summary. +## 4. Planning Mode direct task handoff + +- **File:** `packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx` +- **Exact test:** `PlanningModeModal sequential flow > creates the task directly and offers task and session-list handoffs` +- **Observed tree/SHA:** `4e21f53996` (FN-8757 worktree) +- **Observed frequency:** first observation in the targeted file run. + +| run | result | +|---|---| +| targeted file run | **1 failed** / 56 passed; `mockCreateTaskFromPlanning` was not called and jsdom reported unimplemented `window.scrollTo()` | +| isolated exact test | passed | + +The failure is unrelated to the mobile question footer: it exercises the completed-plan Proceed handoff, while FN-8757 changes only the active-question footer. The file retains substantial coverage, so this first sighting is recorded rather than quarantined; a second sighting requires the normal file-level quarantine. + ## Common shape and unverified suspicion All three are PostgreSQL-backed or PostgreSQL-suite-adjacent, pass in isolation, and appear only under full-suite parallelism. This points at shared database state between test files rather than any of the three tests. It is **unverified and uninvestigated**, not a diagnosis; do not infer a root-cause fix from this record. diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index f48120e6de..3180235b7f 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -781,6 +781,21 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false); const [mobileWorkspaceTab, setMobileWorkspaceTab] = useState<"question" | "plan">("question"); /* + FNXC:PlanningMode 2026-08-03-09:21: + Mobile interviews earn a reversible Plan preview shortcut only after five actual completed + question-and-response pairs. Reasoning-only, malformed, and blank response records never + advance the threshold; Review plan only selects the already-mounted plan tab, so it neither + submits nor clears the operator's current answer and Questions can restore that same form. + */ + const answeredQuestionCount = useMemo(() => conversationHistory.filter((entry) => ( + typeof entry.question?.id === "string" + && entry.question.id.trim().length > 0 + && entry.response !== null + && typeof entry.response === "object" + && !Array.isArray(entry.response) + && Object.keys(entry.response).length > 0 + )).length, [conversationHistory]); + /* FNXC:PlanningMode 2026-07-20-21:50: Refine accepts one freeform instruction instead of generated category choices. The instruction guides both the regenerated plan and its next questions, resets when canceled, and must contain @@ -4110,6 +4125,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat ? conversationHistory.find((entry) => entry.question?.id === editingQuestionId)?.response : undefined} onSubmit={handleSubmitResponse} + showMobilePlanReview={isMobile && answeredQuestionCount >= 5} + onReviewPlan={() => setMobileWorkspaceTab("plan")} /> )} @@ -4326,12 +4343,16 @@ interface QuestionFormProps { question: PlanningQuestion; initialResponse?: QuestionResponse; onSubmit: (responses: QuestionResponse) => void; + /** Enables the parent-owned mobile Plan preview transition after five completed answers. */ + showMobilePlanReview?: boolean; + /** Changes only the parent-owned workspace tab; it must not submit this form. */ + onReviewPlan?: () => void; projectId?: string; } // FNXC:VoiceInput 2026-07-25-19:20: Export the real interview surface for dictation // contract tests instead of substituting a fixture that could drift from this textarea. -export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit, projectId }: QuestionFormProps) { +export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit, showMobilePlanReview = false, onReviewPlan, projectId }: QuestionFormProps) { const { t } = useTranslation("app"); const question = normalizeQuestionOptions(rawQuestion); const questionOptions = question.options ?? []; @@ -4680,9 +4701,16 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit, onClick={handleSubmit} disabled={!isValid()} > - {t("planning.nextQuestion", "Next")} + {showMobilePlanReview + ? t("planning.nextQuestionAction", "Next question") + : t("planning.nextQuestion", "Next")} + {showMobilePlanReview && ( + + )} ); 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 e36f1b06a1..1e171e92ec 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -38,6 +38,13 @@ const summaryWithRefinements = { suggestedRefinements: ["Security boundaries", "Rollout strategy", "Failure recovery", "Accessibility", "Observability"], }; +function answeredHistory(count: number) { + return Array.from({ length: count }, (_, index) => ({ + question: { id: `answered-${index}`, type: "text", question: `Answered question ${index + 1}` }, + response: { [`answered-${index}`]: `Answer ${index + 1}` }, + })); +} + describe("PlanningModeModal sequential flow", () => { beforeEach(() => { vi.useRealTimers(); @@ -845,6 +852,112 @@ describe("PlanningModeModal sequential flow", () => { fireEvent.click(screen.getByRole("button", { name: "Close history" })); }); + it.each([ + { answerCount: 0, label: "no answered questions" }, + { answerCount: 4, label: "four answered questions" }, + ])("keeps the single Next action on mobile with $label", async ({ answerCount }) => { + mockViewportMode.mockReturnValue("mobile"); + mockFetchAiSession.mockResolvedValue({ + ...base, + status: "awaiting_input", + currentQuestion: JSON.stringify({ id: "q-threshold", type: "text", question: "What should mobile prioritize?" }), + result: JSON.stringify(summaryWithRefinements), + conversationHistory: JSON.stringify(answeredHistory(answerCount)), + inputPayload: "{}", + }); + + renderSession(); + + expect(await screen.findByRole("button", { name: "Next" })).toBeDisabled(); + expect(screen.queryByRole("button", { name: "Next question" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Review plan" })).toBeNull(); + }); + + it.each([5, 6])("shows both mobile actions after %i completed answers", async (answerCount) => { + mockViewportMode.mockReturnValue("mobile"); + mockFetchAiSession.mockResolvedValue({ + ...base, + status: "awaiting_input", + currentQuestion: JSON.stringify({ id: "q-threshold", type: "text", question: "What should mobile prioritize?" }), + result: JSON.stringify(summaryWithRefinements), + conversationHistory: JSON.stringify(answeredHistory(answerCount)), + inputPayload: "{}", + }); + + renderSession(); + + expect(await screen.findByRole("button", { name: "Next question" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Review plan" })).toBeEnabled(); + expect(screen.queryByRole("button", { name: "Next" })).toBeNull(); + }); + + it("counts only populated question-and-response entries for the mobile review shortcut", async () => { + mockViewportMode.mockReturnValue("mobile"); + mockFetchAiSession.mockResolvedValue({ + ...base, + status: "awaiting_input", + currentQuestion: JSON.stringify({ id: "q-history-shape", type: "text", question: "What should mobile prioritize?" }), + result: JSON.stringify(summaryWithRefinements), + conversationHistory: JSON.stringify([ + ...answeredHistory(4), + { thinkingOutput: "Reasoning does not answer a question" }, + { question: { id: "malformed", type: "text", question: "Malformed" }, response: {} }, + { question: { type: "text", question: "Missing id" }, response: { answer: "Ignored" } }, + ]), + inputPayload: "{}", + }); + + renderSession(); + + expect(await screen.findByRole("button", { name: "Next" })).toBeDisabled(); + expect(screen.queryByRole("button", { name: "Review plan" })).toBeNull(); + }); + + it("keeps desktop on its single Next action after five answered questions", async () => { + mockFetchAiSession.mockResolvedValue({ + ...base, + status: "awaiting_input", + currentQuestion: JSON.stringify({ id: "q-desktop", type: "text", question: "What should desktop prioritize?" }), + result: JSON.stringify(summaryWithRefinements), + conversationHistory: JSON.stringify(answeredHistory(5)), + inputPayload: "{}", + }); + + renderSession(); + + expect(await screen.findByRole("button", { name: "Next" })).toBeDisabled(); + expect(screen.queryByRole("button", { name: "Next question" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Review plan" })).toBeNull(); + }); + + it("opens Plan preview without submitting and preserves the current mobile answer on return", async () => { + mockViewportMode.mockReturnValue("mobile"); + mockFetchAiSession.mockResolvedValue({ + ...base, + status: "awaiting_input", + currentQuestion: JSON.stringify({ id: "q-review", type: "text", question: "What should mobile prioritize?" }), + result: JSON.stringify(summaryWithRefinements), + conversationHistory: JSON.stringify(answeredHistory(5)), + inputPayload: "{}", + }); + + renderSession(); + + const answer = await screen.findByPlaceholderText("Type your answer here..."); + fireEvent.change(answer, { target: { value: "Keep this unsent answer" } }); + expect(screen.getByRole("button", { name: "Next question" })).toBeEnabled(); + + fireEvent.click(screen.getByRole("button", { name: "Review plan" })); + expect(screen.getByRole("tab", { name: "Plan preview", hidden: true })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByTestId("planning-workspace")).toHaveClass("planning-workspace--mobile-tab-plan"); + expect(mockRespondToPlanning).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("tab", { name: "Questions", hidden: true })); + expect(screen.getByTestId("planning-workspace")).toHaveClass("planning-workspace--mobile-tab-question"); + expect(screen.getByPlaceholderText("Type your answer here...")).toHaveValue("Keep this unsent answer"); + expect(mockRespondToPlanning).not.toHaveBeenCalled(); + }); + it.each([ { viewport: "desktop", status: "awaiting_input", label: "a durable next question" }, { viewport: "mobile", status: "awaiting_input", label: "a durable next question" },