diff --git a/.changeset/improve-sequential-planning-workspace.md b/.changeset/improve-sequential-planning-workspace.md index 78296698dc..1ef9e8b10a 100644 --- a/.changeset/improve-sequential-planning-workspace.md +++ b/.changeset/improve-sequential-planning-workspace.md @@ -2,6 +2,6 @@ "@runfusion/fusion": patch --- -summary: Keep the evolving plan visible while Planning Mode asks sequential questions. +summary: Add mobile Planning tabs, one-click task creation, and answer/reasoning history. category: fix -dev: Adds responsive plan/question panes, a Next action, focused refinement, and a full-workspace generation state. +dev: Adds a header history sheet, keeps desktop lanes side by side, and uses full-height mobile tabs. diff --git a/packages/dashboard/app/components/PlanningModeModal.css b/packages/dashboard/app/components/PlanningModeModal.css index 78976ee290..e7a1134dfb 100644 --- a/packages/dashboard/app/components/PlanningModeModal.css +++ b/packages/dashboard/app/components/PlanningModeModal.css @@ -137,6 +137,23 @@ The embedded planning header must be a visual SIBLING of MissionManager's inline flex-shrink: 0; } +.planning-header-controls { + display: flex; + align-items: center; + gap: var(--space-sm); + flex-shrink: 0; +} + +.planning-header-controls .btn { + min-height: calc(var(--space-2xl) + var(--space-sm)); +} + +.planning-history-trigger.active { + color: var(--text); + background: var(--card-hover); + box-shadow: var(--focus-ring); +} + .planning-modal .text-muted { color: var(--text-muted); } @@ -150,6 +167,118 @@ The embedded planning header must be a visual SIBLING of MissionManager's inline position: relative; } +.planning-history-overlay { + position: absolute; + inset: 0; + z-index: var(--z-popover); + display: flex; + justify-content: flex-end; + min-width: 0; + min-height: 0; +} + +.planning-history-backdrop { + position: absolute; + inset: 0; + background: color-mix(in srgb, var(--background) 54%, transparent); + backdrop-filter: blur(var(--space-xs)); +} + +.planning-history-panel { + position: relative; + display: flex; + flex-direction: column; + width: min(100%, calc(var(--space-2xl) * 15)); + min-width: 0; + min-height: 0; + border-left: solid var(--btn-border-width) var(--border); + background: var(--surface); + box-shadow: var(--shadow-lg); +} + +.planning-history-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-lg); + padding: var(--space-xl); + border-bottom: solid var(--btn-border-width) var(--border); +} + +.planning-history-heading { + display: flex; + align-items: flex-start; + gap: var(--space-md); + min-width: 0; +} + +.planning-history-heading > svg { + flex-shrink: 0; + margin-top: var(--space-xs); + color: var(--todo); +} + +.planning-history-heading h4, +.planning-history-heading p { + margin: 0; +} + +.planning-history-heading h4 { + text-wrap: balance; +} + +.planning-history-heading p { + margin-top: var(--space-xs); + color: var(--text-muted); + font-size: var(--font-size-sm); + line-height: 1.5; + text-wrap: pretty; +} + +.planning-history-header .btn-icon { + min-width: calc(var(--space-2xl) + var(--space-sm)); + min-height: calc(var(--space-2xl) + var(--space-sm)); + flex-shrink: 0; +} + +.planning-history-scroll { + flex: 1; + min-height: 0; + padding: var(--space-xl); + overflow-y: auto; + overscroll-behavior: contain; +} + +.planning-history-scroll .conversation-history { + max-height: none; + margin: 0; + overflow: visible; +} + +.planning-history-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-sm); + min-height: calc(var(--space-2xl) * 6); + padding: var(--space-xl); + border-radius: var(--radius-xl); + color: var(--text-muted); + text-align: center; + background: var(--card); +} + +.planning-history-empty strong { + color: var(--text); +} + +.planning-history-empty p { + max-width: calc(var(--space-2xl) * 10); + margin: 0; + text-wrap: pretty; +} + .planning-modal-body--split { flex-direction: row; } @@ -935,6 +1064,10 @@ An empty footer must NOT reserve vertical space or paint its divider band. When background: color-mix(in srgb, var(--card) 40%, var(--surface)); } +.planning-workspace-tabs { + display: none; +} + .planning-plan-pane, .planning-question-pane { display: flex; @@ -996,7 +1129,7 @@ An empty footer must NOT reserve vertical space or paint its divider band. When .planning-question { display: flex; flex-direction: column; - gap: 20px; + gap: var(--space-xl); flex: 1; min-height: 0; } @@ -1010,17 +1143,17 @@ An empty footer must NOT reserve vertical space or paint its divider band. When } .planning-question-scroll { - gap: 16px; + gap: var(--space-lg); padding-top: 0; } .planning-question-panel { display: flex; flex-direction: column; - gap: 16px; - padding: 16px 20px 20px; + gap: var(--space-lg); + padding: var(--space-lg) var(--space-xl) var(--space-xl); background: var(--card); - border: 1px solid var(--border); + border: var(--btn-border-width) solid var(--border); border-radius: var(--radius-lg); } @@ -1131,9 +1264,10 @@ margins so paragraphs and lists do not create unintended gaps. display: flex; align-items: flex-start; gap: var(--space-md); - padding: 14px 16px; + min-height: calc(var(--space-2xl) + var(--space-md)); + padding: var(--space-md) var(--space-lg); background: var(--surface); - border: 1px solid var(--border); + border: var(--btn-border-width) solid var(--border); border-radius: var(--radius-lg); cursor: pointer; transition: @@ -1518,13 +1652,14 @@ its refinement menu remain reachable at the bottom of bounded desktop and mobile .planning-plan-actions { position: relative; justify-content: flex-end; - gap: var(--space-md); - padding: var(--space-lg) var(--space-xl); + gap: var(--space-lg); + padding: var(--space-md) var(--space-xl) var(--space-sm); background: color-mix(in srgb, var(--surface) 94%, transparent); } .planning-plan-actions .btn { min-width: calc(var(--space-2xl) * 4); + min-height: calc(var(--space-2xl) + var(--space-sm)); } .planning-refine-menu { @@ -1655,8 +1790,8 @@ its refinement menu remain reachable at the bottom of bounded desktop and mobile .planning-plan-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-sm); - padding: var(--space-md) var(--space-lg) calc(var(--space-lg) + env(safe-area-inset-bottom)); + gap: var(--space-md); + padding: var(--space-md) var(--space-lg) calc(var(--space-sm) + env(safe-area-inset-bottom)); } .planning-plan-actions .btn { @@ -1941,16 +2076,105 @@ Tablet embedded Planning keeps the desktop two-pane shell, so the summary footer /* Responsive */ @media (max-width: 768px) { + .planning-modal--embedded .modal-header--embedded { + align-content: center; + flex-wrap: wrap; + gap: var(--space-md); + padding: var(--space-md) var(--space-lg); + } + + .planning-modal--embedded .modal-header--embedded .detail-title-row { + flex: 1 1 100%; + min-width: 0; + } + + .planning-modal--embedded .modal-header--embedded .detail-title-row h3 { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .planning-header-controls { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; + } + + .planning-header-controls .btn { + justify-content: center; + min-width: 0; + } + + .planning-history-panel { + width: 100%; + border-left: 0; + } + + .planning-history-header, + .planning-history-scroll { + padding: var(--space-lg); + } + .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--mobile-tab-question, + .planning-workspace--mobile-tab-plan { + grid-template-areas: + "tabs" + "content"; + grid-template-rows: auto minmax(0, 1fr); + } + + .planning-workspace-tabs { + grid-area: tabs; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-xs); + padding: var(--space-xs); + border: solid var(--btn-border-width) var(--border); + border-radius: var(--radius-lg); + background: var(--card); + } + + .planning-workspace-tabs button { + min-height: calc(var(--space-2xl) + var(--space-sm)); + padding: var(--space-sm) var(--space-md); + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: var(--text-muted); + font: inherit; + font-weight: 600; + cursor: pointer; + transition: background-color var(--transition-fast), color var(--transition-fast), box-shadow var(--transition-fast); + } + + .planning-workspace-tabs button.active { + background: var(--surface); + color: var(--text); + box-shadow: var(--shadow-sm); + } + + .planning-workspace-tabs button:focus-visible { + outline: none; + box-shadow: var(--focus-ring-strong); + } + + .planning-workspace--mobile-tab-question .planning-question-pane, + .planning-workspace--mobile-tab-plan .planning-plan-pane { + grid-area: content; + } + + .planning-workspace--mobile-tab-question .planning-plan-pane, + .planning-workspace--mobile-tab-plan .planning-question-pane { + display: none; + } + .planning-workspace--plan-only { grid-template-areas: "plan"; grid-template-rows: minmax(0, 1fr); @@ -2004,6 +2228,11 @@ Tablet embedded Planning keeps the desktop two-pane shell, so the summary footer align-items: stretch; } + .planning-plan-actions { + gap: var(--space-md); + padding: var(--space-sm) var(--space-lg) calc(var(--space-sm) + env(safe-area-inset-bottom)); + } + .planning-actions .btn, .planning-start-btn { width: 100%; diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index 189e71a560..6df2f49e96 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -55,7 +55,7 @@ import { clearPlanningActiveSession, } from "../hooks/modalPersistence"; import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; -import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, RefreshCw, ChevronLeft, MessageSquarePlus, AlertCircle, Clock, HelpCircle, StopCircle, Archive, ArchiveRestore, Pencil } from "lucide-react"; +import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, RefreshCw, ChevronLeft, MessageSquarePlus, AlertCircle, Clock, HelpCircle, StopCircle, Archive, ArchiveRestore, Pencil, History } from "lucide-react"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { ConversationHistory } from "./ConversationHistory"; import { MailboxMessageContent } from "./MailboxMessageContent"; @@ -501,6 +501,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat Session navigation is an explicit header action so a transient turn cannot replace the three-pane workspace. */ const [showSessionList, setShowSessionList] = useState(false); + const [isHistoryOpen, setIsHistoryOpen] = useState(false); const [loadedModels, setLoadedModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); const [modelsError, setModelsError] = useState(null); @@ -513,6 +514,29 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat // Sidebar list state const [planningSessions, setPlanningSessions] = useState(() => dedupeSessionsById(initialSessions ?? [])); + const historyCloseRef = useRef(null); + const historyTriggerRef = useRef(null); + const closeHistory = useCallback(() => { + setIsHistoryOpen(false); + requestAnimationFrame(() => historyTriggerRef.current?.focus()); + }, []); + const historyPanelEntries = useMemo(() => { + const liveReasoning = streamingOutput.trim(); + if (!liveReasoning || conversationHistory[conversationHistory.length - 1]?.thinkingOutput === liveReasoning) { + return conversationHistory; + } + return [...conversationHistory, { thinkingOutput: liveReasoning }]; + }, [conversationHistory, streamingOutput]); + + useEffect(() => { + if (!isHistoryOpen) return; + historyCloseRef.current?.focus(); + const handleHistoryEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") closeHistory(); + }; + document.addEventListener("keydown", handleHistoryEscape); + return () => document.removeEventListener("keydown", handleHistoryEscape); + }, [closeHistory, isHistoryOpen]); const [sessionsLoading, setSessionsLoading] = useState(false); const [selectedSessionId, setSelectedSessionId] = useState(resumeSessionId ?? null); // Mobile: when the modal is narrow, only one pane is visible at a time. @@ -561,8 +585,15 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat // FNXC:PlanningModeMobile 2026-07-20-10:30: Empty mobile state opens the composer because no saved destination exists; once sessions exist, every compact detail surface gets this single Back-to-list escape. const canReturnToSessionList = isCompactInterview && mobileShowDetail && planningSessions.length > 0; const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false); + const [mobileWorkspaceTab, setMobileWorkspaceTab] = useState<"question" | "plan">("question"); const [selectedRefineFocuses, setSelectedRefineFocuses] = useState([]); const [customRefineFocus, setCustomRefineFocus] = useState(""); + + useEffect(() => { + if (isMobile && workspaceQuestion) { + setMobileWorkspaceTab("question"); + } + }, [isMobile, workspaceQuestion?.id]); const refineMenuRef = useRef(null); const refineTriggerRef = useRef(null); const { addToast } = useToast(); @@ -1057,9 +1088,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat /* 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. + question. It refreshes the right running-plan pane only; Proceed is the sole action + allowed to validate and create the task, preventing a first-answer SSE race from ending + the interview or exposing an intermediate final screen. */ runningSummaryRef.current = normalizedSummary; setRunningSummary(normalizedSummary); @@ -2334,28 +2365,26 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat validateCreateInFlightRef.current = true; setError(null); setView({ type: "creating_task", session, summary }); + let validationCompleted = false; try { - try { - 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(sessionId).catch(() => null); - if (persisted && isValidatedPlanningSession(persisted)) { - 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, summary }); - } - return; - } - const task = await createTaskFromPlanning(sessionId, summary, projectId, { ...(workflowId !== undefined ? { workflowId } : {}) }); + const validated = await validatePlanningSession(sessionId, projectId); + validationCompleted = true; + const validatedSummary = normalizePlanningSummary(validated.summary); + const task = await createTaskFromPlanning(sessionId, validatedSummary, projectId, { + ...(workflowId !== undefined ? { workflowId } : {}), + }); onTaskCreated(task); clearPlanningActiveSession(projectId); setSelectedSessionId(null); handleClose(); } catch (err) { - setView({ type: "create_retry", session, summary, errorMessage: getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task") }); + const errorMessage = getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task"); + if (validationCompleted) { + setView({ type: "create_retry", session, summary, errorMessage }); + } else { + setError(errorMessage); + setView({ type: "plan_review", session, summary }); + } } finally { validateCreateInFlightRef.current = false; } @@ -2550,7 +2579,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat if (!isOpen) return null; const renderPlanPane = (summary: PlanningSummary) => ( -
+