diff --git a/.changeset/improve-sequential-planning-workspace.md b/.changeset/improve-sequential-planning-workspace.md new file mode 100644 index 0000000000..78296698dc --- /dev/null +++ b/.changeset/improve-sequential-planning-workspace.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep the evolving plan visible while Planning Mode asks sequential questions. +category: fix +dev: Adds responsive plan/question panes, a Next action, focused refinement, and a full-workspace generation state. diff --git a/packages/dashboard/app/components/PlanningModeModal.css b/packages/dashboard/app/components/PlanningModeModal.css index 90ac07c295..78976ee290 100644 --- a/packages/dashboard/app/components/PlanningModeModal.css +++ b/packages/dashboard/app/components/PlanningModeModal.css @@ -921,6 +921,78 @@ An empty footer must NOT reserve vertical space or paint its divider band. When } /* Question View */ +.planning-workspace { + position: relative; + display: grid; + grid-template-areas: "question plan"; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.18fr); + gap: var(--space-lg); + flex: 1; + min-width: 0; + min-height: 0; + padding: var(--space-lg); + overflow: hidden; + background: color-mix(in srgb, var(--card) 40%, var(--surface)); +} + +.planning-plan-pane, +.planning-question-pane { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; + border: solid var(--btn-border-width) color-mix(in srgb, var(--border) 82%, transparent); + border-radius: var(--radius-xl); + background: var(--surface); + box-shadow: var(--shadow-sm); +} + +.planning-plan-pane { + grid-area: plan; +} + +.planning-workspace--plan-only { + grid-template-areas: "plan"; + grid-template-columns: minmax(0, 1fr); +} + +.planning-question-pane { + grid-area: question; +} + +.planning-workspace--generating > .planning-plan-pane, +.planning-workspace--generating > .planning-question-pane { + pointer-events: none; + filter: saturate(0.65); + opacity: 0.58; +} + +.planning-workspace-loader { + position: absolute; + inset: 0; + z-index: var(--z-popover); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-md); + padding: var(--space-xl); + color: var(--text); + text-align: center; + background: color-mix(in srgb, var(--surface) 72%, transparent); + backdrop-filter: blur(var(--space-xs)); +} + +.planning-workspace-loader strong { + font-size: var(--font-size-base); +} + +.planning-workspace-loader span { + color: var(--text-secondary); + font-size: var(--font-size-sm); +} + .planning-question { display: flex; flex-direction: column; @@ -1210,6 +1282,10 @@ its refinement menu remain reachable at the bottom of bounded desktop and mobile gap: 0; } +.planning-plan-review > .planning-plan-pane { + flex: 1; +} + .planning-plan-document { width: min(100%, calc(var(--space-2xl) * 24)); } @@ -1865,6 +1941,26 @@ Tablet embedded Planning keeps the desktop two-pane shell, so the summary footer /* Responsive */ @media (max-width: 768px) { + .planning-workspace { + grid-template-areas: + "plan" + "question"; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); + gap: var(--space-md); + padding: var(--space-md); + } + + .planning-workspace--plan-only { + grid-template-areas: "plan"; + grid-template-rows: minmax(0, 1fr); + } + + .planning-plan-pane, + .planning-question-pane { + border-radius: var(--radius-lg); + } + /* FNXC:PlanningMode 2026-06-22-15:30: this legacy full-viewport sheet sizing is for the dialog presentation only. The :not(.planning-modal--embedded) guard prevents the embedded view from being yanked to 100vw/100dvh, which diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index 1cf4c972b3..189e71a560 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -355,6 +355,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat // FNXC:PlanningMode 2026-07-19-15:35: FN-8400 keeps the in-progress plan independent of the center-pane view so it remains visible while the next question is generating. const [runningSummary, setRunningSummary] = useState(null); const runningSummaryRef = useRef(null); + const [workspaceQuestion, setWorkspaceQuestion] = useState(null); const [branchMode, setBranchMode] = useState<"project-default" | "auto-new" | "existing" | "custom-new">("project-default"); const [branchName, setBranchName] = useState(""); const [baseBranch, setBaseBranch] = useState(""); @@ -772,8 +773,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat if (cancelled || !session) return; if (currentSessionIdRef.current !== sessionId) return; if (session.status === "awaiting_input" && !session.currentQuestion && session.result) { - // The initial plan and answer updates intentionally settle without a question. - // Recover that plan when its SSE summary event was missed instead of polling forever. + // Recover a legacy or partially persisted plan when its question event was missed. + // New sequential turns normally settle with both result and currentQuestion. resetPlanningAutoRetryBudget(); const history = parseConversationHistory(session.conversationHistory); const summary = normalizePlanningSummary(JSON.parse(session.result) as PlanningSummary); @@ -807,6 +808,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat .map((entry) => entry.response) .filter((response): response is QuestionResponse => Boolean(response && typeof response === "object" && !Array.isArray(response)))); setRunningSummary(summary); + setWorkspaceQuestion(question); setView({ type: "question", session: { sessionId, currentQuestion: question, summary }, @@ -886,6 +888,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setConversationHistory([]); setEditedSummary(null); setRunningSummary(null); + setWorkspaceQuestion(null); setLoadedSessionTitle(null); setBranchMode("project-default"); setBranchName(""); @@ -993,6 +996,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat onQuestion: (question) => { if (isStaleEvent()) return; const normalizedQuestion = normalizeQuestionOptions(question); + setWorkspaceQuestion(normalizedQuestion); const isAnsweredQuestion = conversationHistoryRef.current.some( (entry) => entry.question?.id === normalizedQuestion.id && entry.response !== undefined, ); @@ -1059,9 +1063,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat */ runningSummaryRef.current = normalizedSummary; setRunningSummary(normalizedSummary); - setView((previous) => previous.type === "loading" - ? { type: "plan_review", session: { sessionId, currentQuestion: null, summary: normalizedSummary }, summary: normalizedSummary } - : previous.type === "question" + setView((previous) => previous.type === "question" ? { ...previous, session: { ...previous.session, summary: normalizedSummary } } : previous); setStreamingOutput(""); @@ -1414,6 +1416,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setConversationHistory([]); setEditedSummary(null); setRunningSummary(null); + setWorkspaceQuestion(null); setLoadedSessionTitle(null); setIsRetrying(false); setIsRefiningSummary(false); @@ -1486,6 +1489,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat ? normalizePlanningSummary(JSON.parse(session.result)) : null; setRunningSummary(persistedRunningSummary); + if (session.status === "generating") { + setWorkspaceQuestion(parsedHistory.at(-1)?.question ?? null); + } if (session.status === "error") { const errorMessage = session.error || t("planning.sessionFailed2", "Session failed"); @@ -1543,6 +1549,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat resetPlanningAutoRetryBudget(); clearPlanningDescription(projectId); const question = normalizeQuestionOptions(JSON.parse(session.currentQuestion)); + setWorkspaceQuestion(question); setView({ type: "question", session: { sessionId, currentQuestion: question, summary: persistedRunningSummary } }); // Transfer persisted thinking into conversation history so it's // visible as expandable reasoning in the question view, instead of @@ -2159,12 +2166,23 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat liveGenerationSessionIdRef.current = sessionId; try { - // Submit response - AI will broadcast events via the already-connected stream + // Submit response. SSE remains the primary live path, while the HTTP payload closes + // the gap for restored sessions whose original stream is no longer connected. const response = await respondToPlanning(sessionId, responses, projectId); - // FNXC:PlanningMode 2026-07-20-15:45: FN-8442 makes the SSE summary the - // sole answer-turn transition authority. An answer updates the plan and clears - // the question; only the explicit Refine request may publish another question. - void response; + const responseQuestion = "type" in response ? response.data : response.currentQuestion; + const responseSummary = "type" in response ? null : response.summary; + const nextSummary = responseSummary + ? normalizePlanningSummary(responseSummary) + : runningSummaryRef.current; + if (nextSummary) { + runningSummaryRef.current = nextSummary; + setRunningSummary(nextSummary); + } + if (responseQuestion) { + const nextQuestion = normalizeQuestionOptions(responseQuestion); + setWorkspaceQuestion(nextQuestion); + setView({ type: "question", session: { sessionId, currentQuestion: nextQuestion, summary: nextSummary } }); + } } catch (err) { const errorMessage = getErrorMessage(err) || t("planning.failedSubmitResponse", "Failed to submit response"); /* @@ -2199,6 +2217,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat .filter((response): response is QuestionResponse => Boolean(response && typeof response === "object" && !Array.isArray(response)))); setRunningSummary(summary); setError(errorMessage); + setWorkspaceQuestion(currentQuestion); setView({ type: "question", session: { sessionId, currentQuestion, summary } }); return; } @@ -2221,6 +2240,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat conversationHistoryRef.current = historyBeforeSubmit; setConversationHistory(historyBeforeSubmit); setError(errorMessage); + setWorkspaceQuestion(activeQuestion); setView({ type: "question", session: { ...session, summary: runningSummaryRef.current } }); } }, @@ -2268,24 +2288,28 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat may finalize during loading or recoverable error states; the server cancels an active turn safely. */ const handleRefineFromPlan = useCallback(async () => { - if (view.type !== "plan_review" || !combinedRefineFocus) return; + const sessionId = currentSessionIdRef.current; + const summary = runningSummaryRef.current; + if (!sessionId || !summary || !combinedRefineFocus) return; setError(null); setGenerationActivity("question"); setIsRefineMenuOpen(false); setView({ type: "loading" }); try { - const response = await respondToPlanning(view.session.sessionId, { refine: true, focus: combinedRefineFocus }, projectId); + const response = await respondToPlanning(sessionId, { refine: true, focus: combinedRefineFocus }, projectId); const responseQuestion = "type" in response ? response.data : response.currentQuestion; const responseSummary = "type" in response ? null : response.summary; - const nextSummary = responseSummary ? normalizePlanningSummary(responseSummary) : view.summary; + const nextSummary = responseSummary ? normalizePlanningSummary(responseSummary) : summary; runningSummaryRef.current = nextSummary; setRunningSummary(nextSummary); if (responseQuestion) { + const nextQuestion = normalizeQuestionOptions(responseQuestion); + setWorkspaceQuestion(nextQuestion); setView({ type: "question", session: { - sessionId: view.session.sessionId, - currentQuestion: normalizeQuestionOptions(responseQuestion), + sessionId, + currentQuestion: nextQuestion, summary: nextSummary, }, }); @@ -2294,41 +2318,48 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setCustomRefineFocus(""); } catch (err) { setError(getErrorMessage(err) || t("planning.failedSubmitResponse", "Failed to refine plan")); - setView({ type: "plan_review", session: view.session, summary: view.summary }); + if (workspaceQuestion) { + setView({ type: "question", session: { sessionId, currentQuestion: workspaceQuestion, summary } }); + } else { + setView({ type: "plan_review", session: { sessionId, currentQuestion: null, summary }, summary }); + } } - }, [combinedRefineFocus, projectId, t, view]); + }, [combinedRefineFocus, projectId, t, workspaceQuestion]); const handleValidatePlan = useCallback(async () => { - if (view.type !== "plan_review" || validateCreateInFlightRef.current) return; + const sessionId = currentSessionIdRef.current; + const summary = runningSummaryRef.current; + if (!sessionId || !summary || validateCreateInFlightRef.current) return; + const session = { sessionId, currentQuestion: workspaceQuestion, summary }; validateCreateInFlightRef.current = true; setError(null); - setView({ type: "creating_task", session: view.session, summary: view.summary }); + setView({ type: "creating_task", session, summary }); try { try { - await validatePlanningSession(view.session.sessionId, projectId); + await validatePlanningSession(sessionId, projectId); } catch (err) { // A lost validation response is ambiguous, but a rejected validation must not strand // the user in create-only retry: inspect durable state before choosing the next phase. - const persisted = await fetchAiSession(view.session.sessionId).catch(() => null); + const persisted = await fetchAiSession(sessionId).catch(() => null); if (persisted && isValidatedPlanningSession(persisted)) { - setView({ type: "create_retry", session: view.session, summary: view.summary, errorMessage: getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task") }); + setView({ type: "create_retry", session, summary, errorMessage: getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task") }); } else { setError(getErrorMessage(err) || t("planning.failedCreateTask", "Failed to validate plan")); - setView({ type: "plan_review", session: view.session, summary: view.summary }); + setView({ type: "plan_review", session, summary }); } return; } - const task = await createTaskFromPlanning(view.session.sessionId, view.summary, projectId, { ...(workflowId !== undefined ? { workflowId } : {}) }); + const task = await createTaskFromPlanning(sessionId, summary, projectId, { ...(workflowId !== undefined ? { workflowId } : {}) }); onTaskCreated(task); clearPlanningActiveSession(projectId); setSelectedSessionId(null); handleClose(); } catch (err) { - setView({ type: "create_retry", session: view.session, summary: view.summary, errorMessage: getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task") }); + setView({ type: "create_retry", session, summary, errorMessage: getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task") }); } finally { validateCreateInFlightRef.current = false; } - }, [handleClose, onTaskCreated, projectId, t, view, workflowId]); + }, [handleClose, onTaskCreated, projectId, t, workflowId, workspaceQuestion]); const handleRetryCreateTask = useCallback(async () => { if (view.type !== "create_retry" || validateCreateInFlightRef.current) return; @@ -2482,6 +2513,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const nextSummary = rewound.summary ? normalizePlanningSummary(rewound.summary) : runningSummaryRef.current; runningSummaryRef.current = nextSummary; setRunningSummary(nextSummary); + setWorkspaceQuestion(rewound.currentQuestion); setView({ type: "question", session: { ...view.session, currentQuestion: rewound.currentQuestion, summary: nextSummary } }); } catch (err) { setError(getErrorMessage(err) || t("planning.failedGoBack", "Failed to edit the selected answer")); @@ -2517,6 +2549,94 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat */ if (!isOpen) return null; + const renderPlanPane = (summary: PlanningSummary) => ( +
+
+
+ +
+
+
+ {isRefineMenuOpen && ( +