FN-8757: add mobile planning review shortcut
Add a reversible Plan preview shortcut after five answered mobile interview questions. - Count only populated question-and-response history entries toward the threshold. - Preserve unsent answers while switching between mobile question and plan tabs. - Add coverage, operator documentation, and a patch changeset. Files changed: .changeset/fn-8757-mobile-planning-review.md | 7 ++ docs/dashboard-guide.md | 3 + .../suite-only-flakes-observed-register.md | 14 +++ .../dashboard/app/components/PlanningModeModal.tsx | 32 +++++- .../PlanningModeModal.planning-flow.test.tsx | 113 +++++++++++++++++++++ 5 files changed, 167 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-8757 Fusion-Task-Lineage: ac92a785-aafd-4c92-a1af-b089c21dbb99 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8757-mobile-planning-review.md
Normal file
7
.changeset/fn-8757-mobile-planning-review.md
Normal file
@@ -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.
|
||||
@@ -600,6 +600,9 @@ Planning Mode uses a dedicated collaborative system prompt. It investigates rele
|
||||
<!-- FNXC:PlanningMode 2026-07-20-15:45: FN-8442 replaces the simultaneous three-pane interview with a sequential plan-review and question loop. -->
|
||||
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.
|
||||
|
||||
<!-- FNXC:PlanningMode 2026-08-03-09:21: After five completed question-and-response pairs, mobile interview footers expose Review plan as a non-submitting shortcut to the existing Plan preview tab. The Questions tab remains available, preserving any unsent response so operators can review and continue without losing their draft. -->
|
||||
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.
|
||||
|
||||
<!-- FNXC:PlanningMode 2026-07-20-12:00: FN-8441 separates the lean Planning Mode artifact from triage's executor specification. -->
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
@@ -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")}
|
||||
<ArrowRight size={16} className="icon-ml-4" />
|
||||
</button>
|
||||
{showMobilePlanReview && (
|
||||
<button className="btn" type="button" onClick={onReviewPlan}>
|
||||
{t("planning.reviewPlan", "Review plan")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user