From 8166c80bad5751c9802f2b5769fe2d856546ad4f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 20 Jul 2026 02:01:58 -0700 Subject: [PATCH] FN-8420: keep Planning Mode interviews open until validation Keep the Planning Mode workspace active until the operator explicitly validates its running plan. - Preserve the answered-question, current-question, and running-plan panes through stream updates and recoverable states. - Require durable validation before exposing terminal summaries and task creation actions. - Make every answer request one new high-impact question and update Planning Mode guidance, coverage, and release notes. Files changed: .../fn-8420-planning-mode-user-validation.md | 7 + docs/dashboard-guide.md | 6 +- docs/settings-reference.md | 2 +- .../dashboard/app/components/PlanningModeModal.tsx | 163 +++++++++++++-------- .../__tests__/PlanningModeModal.initial.test.tsx | 20 ++- .../PlanningModeModal.planning-flow.test.tsx | 99 +++++++++---- .../PlanningModeModal.ui-interactions.test.tsx | 14 ++ .../app/planning-browser-e2e-fixture.html | 5 + .../dashboard/app/planning-browser-e2e-fixture.tsx | 131 +++++++++++++++++ .../src/__tests__/planning-browser-e2e.test.ts | 115 +++++++++++++++ .../__tests__/planning-infinite-interview.test.ts | 2 + .../planning-interview-formatters.test.ts | 8 + packages/dashboard/src/planning.ts | 16 +- 13 files changed, 485 insertions(+), 103 deletions(-) Fusion-Task-Id: FN-8420 Fusion-Task-Lineage: af88729b-98c1-4141-bad3-a17ddb497181 Co-authored-by: Fusion (runfusion.ai) --- .../fn-8420-planning-mode-user-validation.md | 7 + docs/dashboard-guide.md | 6 +- docs/settings-reference.md | 2 +- .../app/components/PlanningModeModal.tsx | 163 +++++++++++------- .../PlanningModeModal.initial.test.tsx | 20 +-- .../PlanningModeModal.planning-flow.test.tsx | 99 ++++++++--- ...PlanningModeModal.ui-interactions.test.tsx | 14 ++ .../app/planning-browser-e2e-fixture.html | 5 + .../app/planning-browser-e2e-fixture.tsx | 131 ++++++++++++++ .../__tests__/planning-browser-e2e.test.ts | 115 ++++++++++++ .../planning-infinite-interview.test.ts | 2 + .../planning-interview-formatters.test.ts | 8 + packages/dashboard/src/planning.ts | 16 +- 13 files changed, 485 insertions(+), 103 deletions(-) create mode 100644 .changeset/fn-8420-planning-mode-user-validation.md create mode 100644 packages/dashboard/app/planning-browser-e2e-fixture.html create mode 100644 packages/dashboard/app/planning-browser-e2e-fixture.tsx create mode 100644 packages/dashboard/src/__tests__/planning-browser-e2e.test.ts diff --git a/.changeset/fn-8420-planning-mode-user-validation.md b/.changeset/fn-8420-planning-mode-user-validation.md new file mode 100644 index 0000000000..21504297d8 --- /dev/null +++ b/.changeset/fn-8420-planning-mode-user-validation.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep Planning Mode interviews open until you explicitly validate the running plan. +category: fix +dev: Preserves the active three-pane workspace across summary, loading, and recoverable-error events. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index b369af475b..b3e91a3a2a 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -529,7 +529,7 @@ When an active Planning AI generation appears stuck, Planning Mode automatically Use **New session** to restart planning with a different idea. -Planning Mode keeps the running plan visible beside answered-question history and the current question; you can rename a session and keep asking high-impact, context-aware questions until you choose **Validate plan**. The running title, description, and deliverables are available throughout the interview; the AI never ends it on its own. Selection questions provide alternatives with pros and cons plus an **Other** free-text choice, whose wording follows your input language and whose answer steers the next question. You may edit an earlier answer by question ID without losing later answers; Planning re-derives the running plan and appends a fresh next question. +Planning Mode keeps the running plan visible beside answered-question history and the current question; you can rename a session and keep asking high-impact, context-aware questions until you choose **Validate plan**. The running title, description, and deliverables are available throughout the interview—including while the next question is generating or a recoverable error is shown; use **Sessions** to return to the saved-session list. The AI never ends an interview on its own. Selection questions provide alternatives with pros and cons plus an **Other** free-text choice, whose wording follows your input language and whose answer steers the next question. You may edit an earlier answer by question ID without losing later answers; Planning re-derives the running plan and appends a fresh next question. Choose **Validate plan** when the running plan is ready for task creation. Validation is durable and is required before **Create task**, **Create tasks**, or **Start breakdown**; those actions reject unvalidated sessions. @@ -2124,9 +2124,9 @@ If the endpoint is unavailable on the running dashboard build, the response will ### Planner clarification notifications -Planning Mode always asks and waits for at least one clarifying question before producing a plan. The advanced **follow-up clarification questions** setting is a per-session override initialized from the global preference: when disabled, Planning Mode still asks one mandatory question, then requests a final summary after the answer; when enabled, it may ask further proactive questions. The final summary deepening checkpoint is unchanged. + -When enabled, a proactive question holds the planner at `awaiting_input`, sends the configured `planning-awaiting-input` ntfy event, and delivers a dashboard mailbox message that links the operator back to planner chat. Mailbox delivery does not depend on ntfy configuration and is deduplicated by session/question across restarts. +Planning Mode asks another focused question after every answer until you select **Validate plan**. Each `awaiting_input` question can send the configured `planning-awaiting-input` ntfy event and delivers a dashboard mailbox message that links the operator back to the Planning view. Mailbox delivery does not depend on ntfy configuration and is deduplicated by session/question across restarts. ### Mobile footer quick actions diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 040dfd7e18..1757ae18df 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -71,7 +71,7 @@ Fallback thinking-level values are applied at runtime when Fusion swaps from the | `defaultThinkingLevel` | `"off" \| "minimal" \| "low" \| "medium" \| "high" \| "xhigh"` | `undefined` | Default reasoning effort for AI sessions. `xhigh` requests maximum reasoning effort; Claude CLI adapters map it to `high` for non-Opus models and `max` for Opus models. If a provider/runtime rejects simultaneous `thinking` and `reasoning_effort` parameters, Fusion retries without the explicit thinking override instead of failing the run. | | `ntfyEnabled` | `boolean` | `false` | Enable ntfy push notifications. | -| `agentClarificationEnabled` | `boolean` | `false` | Allow follow-up Planning Mode clarification questions. Planning Mode always asks one mandatory question first; when disabled it requests a final summary after that answer, while enabled follow-ups notify configured ntfy recipients and the dashboard mailbox. | +| `agentClarificationEnabled` | `boolean` | `false` | Legacy default for programmatic Planning Mode session notification eligibility. Dashboard Planning Mode always starts its infinite, user-validated interview with follow-up questions enabled; this setting no longer suppresses questions or creates a final summary. | | `failureNotificationMode` | `"sticky-only" \| "terminal-only" \| "all"` | `"sticky-only"` | Failure notification behavior. `sticky-only` defers failed-task notifications by `failureNotificationDelayMs` and suppresses transient self-recoveries. `terminal-only` suppresses while auto-retry is still active and only dispatches when `paused === true` or `column === "in-review"` with `status === "failed"`. `all` restores legacy immediate failure notifications. | | `failureNotificationDelayMs` | `number` | `30000` | Delay window (ms) before evaluating/sending a `failed` notification in `sticky-only` and `terminal-only` modes. Set `0` for immediate dispatch in legacy `all` mode. | | `ntfyTopic` | `string` | `undefined` | ntfy topic name. | diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index 79e3d3714f..e3e4fbed8a 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -35,7 +35,6 @@ import { summarizePlanningDraftTitle, updatePlanningSessionTitle, updateGlobalSettings, - fetchGlobalSettings, type PlanningSession, type SubtaskItem, type PlanningSubtaskDraft, @@ -107,6 +106,27 @@ type ViewState = | { type: "breakdown"; sessionId: string; originalSubtasks: SubtaskItem[]; subtasks: SubtaskItem[]; dirty: boolean } | { type: "loading" }; +/** + * FNXC:PlanningMode 2026-07-20-00:00: + * A persisted planning `result` is an evolving running plan, not proof that the interview ended. + * Only the explicit Validate action writes this durable marker, so reload and poll paths must use + * it before exposing terminal summary/create-task UI. + */ +function isValidatedPlanningSession(session: { inputPayload?: string | null }): boolean { + try { + const payload: unknown = JSON.parse(session.inputPayload ?? ""); + /* + FNXC:PlanningMode 2026-07-20-01:15: + Terminal Planning UI is an explicit user-validation privilege, not a legacy-session inference. + Missing or malformed persistence may contain a running plan, so only a durable `validated: true` + marker can reveal SummaryView and task-creation actions after reload, polling, or SSE updates. + */ + return typeof payload === "object" && payload !== null && (payload as { validated?: unknown }).validated === true; + } catch { + return false; + } +} + function getExamplePlans(t: TFunction<"app">): string[] { return [ t("planning.examplePlan1", "Build a user authentication system with login and signup"), @@ -446,8 +466,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const [planningModelProvider, setPlanningModelProvider] = useState(undefined); const [planningModelId, setPlanningModelId] = useState(undefined); const [planningThinkingLevel, setPlanningThinkingLevel] = useState(""); - const [clarificationEnabled, setClarificationEnabled] = useState(true); - const [clarificationSettingsLoading, setClarificationSettingsLoading] = useState(true); + /* + FNXC:PlanningMode 2026-07-20-00:45: + An active interview keeps answered history in the left pane during question, generation, and recoverable-error states. + Session navigation is an explicit header action so a transient turn cannot replace the three-pane workspace. + */ + const [showSessionList, setShowSessionList] = useState(false); const [loadedModels, setLoadedModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); const [modelsError, setModelsError] = useState(null); @@ -662,14 +686,23 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat }); setStreamingOutput(""); } else if (session.status === "complete" && session.result) { - resetPlanningAutoRetryBudget(); const summary = normalizePlanningSummary(JSON.parse(session.result) as PlanningSummary); - setView({ - type: "summary", - session: { sessionId, currentQuestion: null, summary }, - summary, - }); - setEditedSummary(summary); + setRunningSummary(summary); + if (isValidatedPlanningSession(session)) { + resetPlanningAutoRetryBudget(); + setView({ + type: "summary", + session: { sessionId, currentQuestion: null, summary }, + summary, + }); + setEditedSummary(summary); + } else { + setView({ + type: "error", + session: { sessionId, currentQuestion: null, summary }, + errorMessage: t("planning.awaitingValidationState", "This plan is still being prepared. Retry to continue the interview."), + }); + } setStreamingOutput(""); } else if (session.status === "error") { const errorMessage = session.error || t("planning.sessionFailed2", "Session failed"); @@ -883,13 +916,20 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat }); } - setView({ - type: "summary", - session: { sessionId, currentQuestion: null, summary: normalizedSummary }, - summary: normalizedSummary, - }); - setEditedSummary(normalizedSummary); + /* + FNXC:PlanningMode 2026-07-20-00:00: + The server broadcasts `summary` on every interview turn before or after its next + question. It refreshes the right running-plan pane only; Validate is the sole action + allowed to enter terminal SummaryView, preventing a first-answer SSE race from ending + the interview. + */ setRunningSummary(normalizedSummary); + setView((previous) => previous.type === "question" + ? { + ...previous, + session: { ...previous.session, summary: normalizedSummary }, + } + : previous); setStreamingOutput(""); }, onError: (message) => { @@ -1024,15 +1064,24 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat if (!session.result) { throw new Error("Planning session is complete but has no result."); } - resetPlanningAutoRetryBudget(); const summary = normalizePlanningSummary(JSON.parse(session.result) as PlanningSummary); - clearPlanningDescription(projectId); - setView({ - type: "summary", - session: { sessionId: session.id, currentQuestion: null, summary }, - summary, - }); - setEditedSummary(summary); + setRunningSummary(summary); + if (isValidatedPlanningSession(session)) { + resetPlanningAutoRetryBudget(); + clearPlanningDescription(projectId); + setView({ + type: "summary", + session: { sessionId: session.id, currentQuestion: null, summary }, + summary, + }); + setEditedSummary(summary); + } else { + setView({ + type: "error", + session: { sessionId: session.id, currentQuestion: null, summary }, + errorMessage: t("planning.awaitingValidationState", "This plan is still being prepared. Retry to continue the interview."), + }); + } } else if (session.status === "error") { setView({ type: "error", @@ -1098,7 +1147,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat startPlanningAutoRetryRef.current = startPlanningAutoRetry; const handleStartPlanning = useCallback(async (planOverride?: string) => { - if (clarificationSettingsLoading) return; const plan = planOverride ?? initialPlan; const startedPlan = plan.trim(); if (!startedPlan) return; @@ -1126,13 +1174,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat startedPlan, projectId, modelOverride, - { clarificationEnabled }, + { clarificationEnabled: true }, draftSessionId ?? undefined, ); draftSessionIdRef.current = null; currentSessionIdRef.current = sessionId; liveGenerationSessionIdRef.current = sessionId; setSelectedSessionId(sessionId); + setShowSessionList(false); connectToPlanningStream(sessionId); setResponseHistory([]); @@ -1143,8 +1192,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat currentSessionIdRef.current = null; } }, [ - clarificationEnabled, - clarificationSettingsLoading, connectToPlanningStream, initialPlan, planningModelId, @@ -1154,18 +1201,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat resetPlanningAutoRetryBudget, ]); - useEffect(() => { - if (!isOpen) return; - let active = true; - setClarificationSettingsLoading(true); - void fetchGlobalSettings() - .then((settings) => { if (active) setClarificationEnabled(settings.agentClarificationEnabled !== false); }) - // A missing settings response keeps the default full interview; disabled only limits follow-ups. - .catch(() => { if (active) setClarificationEnabled(true); }) - .finally(() => { if (active) setClarificationSettingsLoading(false); }); - return () => { active = false; }; - }, [isOpen]); - /* FNXC:PlanningFocus 2026-06-23-00:00: Viewing Planning Mode must not auto-focus the initial composer because mobile browsers open the keyboard before the user chooses to type. Keep the textarea ref for autosize and explicit user focus only; populated initialPlan handoffs still auto-start through the separate effect below. @@ -1180,7 +1215,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat // Auto-start planning when initialPlan prop is provided useEffect(() => { - if (isOpen && initialPlanProp && !clarificationSettingsLoading && !hasAutoStartedRef.current && view.type === "initial") { + if (isOpen && initialPlanProp && !hasAutoStartedRef.current && view.type === "initial") { setInitialPlan(initialPlanProp); // Use a small timeout to allow state update to propagate before starting const timer = setTimeout(() => { @@ -1206,7 +1241,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setInitialPlan(persisted); } } - }, [isOpen, initialPlanProp, clarificationSettingsLoading, view.type, handleStartPlanning, projectId]); + }, [isOpen, initialPlanProp, view.type, handleStartPlanning, projectId]); // Load a specific persisted session into the right pane. const loadSession = useCallback( @@ -1360,11 +1395,20 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat } connectToPlanningStream(sessionId); } else if (session.status === "complete" && session.result) { - resetPlanningAutoRetryBudget(); - clearPlanningDescription(projectId); const summary = persistedRunningSummary ?? normalizePlanningSummary(JSON.parse(session.result)); - setView({ type: "summary", session: { sessionId, currentQuestion: null, summary }, summary }); - setEditedSummary(summary); + setRunningSummary(summary); + if (isValidatedPlanningSession(session)) { + resetPlanningAutoRetryBudget(); + clearPlanningDescription(projectId); + setView({ type: "summary", session: { sessionId, currentQuestion: null, summary }, summary }); + setEditedSummary(summary); + } else { + setView({ + type: "error", + session: { sessionId, currentQuestion: null, summary }, + errorMessage: t("planning.awaitingValidationState", "This plan is still being prepared. Retry to continue the interview."), + }); + } } else if (session.status === "generating") { setView({ type: "loading" }); if (session.thinkingOutput) setStreamingOutput(session.thinkingOutput); @@ -1504,10 +1548,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const handleSelectSession = useCallback( (sessionId: string) => { if (selectedSessionId === sessionId) { + setShowSessionList(false); setMobileShowDetail(true); return; } setSelectedSessionId(sessionId); + setShowSessionList(false); setMobileShowDetail(true); void loadSession(sessionId); }, @@ -1528,11 +1574,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const preserveActiveDraft = selectedSessionId === null && viewRef.current.type === "initial"; resetDetailState({ preserveInitialPlan: preserveActiveDraft }); setSelectedSessionId(null); + setShowSessionList(false); setMobileShowDetail(true); setNewSessionFocusSignal((signal) => signal + 1); }, [resetDetailState, resumeSessionId, selectedSessionId]); const handleBackToList = useCallback(() => { + setShowSessionList(true); setMobileShowDetail(false); }, []); @@ -2172,7 +2220,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat Header icon mirrors MissionManager's : same size (20) and same var(--todo) tint + flex-shrink:0, applied via the scoped .planning-modal--embedded .modal-header--embedded .detail-title-row > svg rule (it overrides the shared icon-triage brown so the two headers read as siblings). */} - {view.type === "question" && activeSessionTitle && isRenamingSession ? ( + {selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error") && activeSessionTitle && isRenamingSession ? ( ) : ( - <>

{view.type === "question" && activeSessionTitle ? activeSessionTitle : t("planning.title", "Planning Mode")}

- {view.type === "question" && activeSessionTitle && } + <>

{selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error") && activeSessionTitle ? activeSessionTitle : t("planning.title", "Planning Mode")}

+ {selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error") && activeSessionTitle && } )} + {selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error") && !isMobile && ( + + )} {!isEmbedded && (
-
- -
@@ -2430,7 +2477,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat