fix(planning): complete interactive planning workspace
Keep plan and questions usable across desktop and mobile, add answer and reasoning history, and create tasks directly from validated plans.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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<ModelInfo[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
@@ -513,6 +514,29 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
|
||||
// Sidebar list state
|
||||
const [planningSessions, setPlanningSessions] = useState<AiSessionSummary[]>(() => dedupeSessionsById(initialSessions ?? []));
|
||||
const historyCloseRef = useRef<HTMLButtonElement>(null);
|
||||
const historyTriggerRef = useRef<HTMLButtonElement>(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<string | null>(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<string[]>([]);
|
||||
const [customRefineFocus, setCustomRefineFocus] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile && workspaceQuestion) {
|
||||
setMobileWorkspaceTab("question");
|
||||
}
|
||||
}, [isMobile, workspaceQuestion?.id]);
|
||||
const refineMenuRef = useRef<HTMLDivElement>(null);
|
||||
const refineTriggerRef = useRef<HTMLButtonElement>(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) => (
|
||||
<section className="planning-plan-pane" data-testid="planning-plan-pane" aria-label={t("planning.currentPlan", "Current plan")}>
|
||||
<section id="planning-plan-panel" className="planning-plan-pane" data-testid="planning-plan-pane" aria-label={t("planning.currentPlan", "Current plan")}>
|
||||
<div className="planning-view-scroll planning-summary-scroll planning-plan-scroll" data-testid="planning-plan-scroll">
|
||||
<article className="planning-plan-document">
|
||||
<MailboxMessageContent
|
||||
@@ -2699,17 +2728,38 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
hidden by the mobile list CSS.
|
||||
*/}
|
||||
{selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error" || view.type === "plan_review" || view.type === "create_retry") && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
const showList = !isSessionListMode;
|
||||
setShowSessionList(showList);
|
||||
if (isCompactInterview) setMobileShowDetail(!showList);
|
||||
}}
|
||||
>
|
||||
{t("planning.sessions", "Sessions")}
|
||||
</button>
|
||||
<div className="planning-header-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
const showList = !isSessionListMode;
|
||||
setIsHistoryOpen(false);
|
||||
setShowSessionList(showList);
|
||||
if (isCompactInterview) setMobileShowDetail(!showList);
|
||||
}}
|
||||
>
|
||||
{t("planning.sessions", "Sessions")}
|
||||
</button>
|
||||
<button
|
||||
ref={historyTriggerRef}
|
||||
type="button"
|
||||
className={`btn planning-history-trigger${isHistoryOpen ? " active" : ""}`}
|
||||
aria-expanded={isHistoryOpen}
|
||||
aria-controls="planning-history-panel"
|
||||
onClick={() => {
|
||||
const nextOpen = !isHistoryOpen;
|
||||
setIsHistoryOpen(nextOpen);
|
||||
if (nextOpen) {
|
||||
setShowSessionList(false);
|
||||
if (isCompactInterview) setMobileShowDetail(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<History size={16} />
|
||||
{t("planning.history", "History")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isEmbedded && (
|
||||
<div className="modal-header-actions">
|
||||
@@ -2725,6 +2775,47 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
isSessionListMode ? "planning-modal-body--show-list" : "planning-modal-body--show-detail"
|
||||
}`}
|
||||
>
|
||||
{isHistoryOpen && (
|
||||
<div className="planning-history-overlay" data-testid="planning-history-overlay">
|
||||
<div className="planning-history-backdrop" aria-hidden="true" onClick={closeHistory} />
|
||||
<section
|
||||
id="planning-history-panel"
|
||||
className="planning-history-panel"
|
||||
role="region"
|
||||
aria-label={t("planning.questionAnswerHistory", "Question and answer history")}
|
||||
>
|
||||
<div className="planning-history-header">
|
||||
<div className="planning-history-heading">
|
||||
<History size={18} />
|
||||
<div>
|
||||
<h4>{t("planning.history", "History")}</h4>
|
||||
<p>{t("planning.historyHint", "Questions, answers, and AI reasoning for each plan update.")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
ref={historyCloseRef}
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
aria-label={t("planning.closeHistory", "Close history")}
|
||||
onClick={closeHistory}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="planning-history-scroll">
|
||||
{historyPanelEntries.length > 0 ? (
|
||||
<ConversationHistory entries={historyPanelEntries} defaultShowThinking />
|
||||
) : (
|
||||
<div className="planning-history-empty">
|
||||
<History size={24} />
|
||||
<strong>{t("planning.noHistoryYet", "No history yet")}</strong>
|
||||
<p>{t("planning.noHistoryHint", "Answered questions and plan-update reasoning will appear here.")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
{isSessionListMode && (
|
||||
<PlanningSessionList
|
||||
sessions={planningSessions}
|
||||
@@ -3033,13 +3124,37 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
|
||||
{(view.type === "question" || view.type === "loading") && runningSummary && (
|
||||
<div
|
||||
className={`planning-workspace${view.type === "loading" ? " planning-workspace--generating" : ""}${workspaceQuestion ? "" : " planning-workspace--plan-only"}`}
|
||||
className={`planning-workspace${view.type === "loading" ? " planning-workspace--generating" : ""}${workspaceQuestion ? ` planning-workspace--mobile-tab-${mobileWorkspaceTab}` : " planning-workspace--plan-only"}`}
|
||||
data-testid="planning-workspace"
|
||||
aria-busy={view.type === "loading"}
|
||||
>
|
||||
{isMobile && workspaceQuestion && (
|
||||
<div className="planning-workspace-tabs" role="tablist" aria-label={t("planning.workspaceViews", "Planning views")}>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mobileWorkspaceTab === "question"}
|
||||
aria-controls="planning-question-panel"
|
||||
className={mobileWorkspaceTab === "question" ? "active" : ""}
|
||||
onClick={() => setMobileWorkspaceTab("question")}
|
||||
>
|
||||
{t("planning.questionsTab", "Questions")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mobileWorkspaceTab === "plan"}
|
||||
aria-controls="planning-plan-panel"
|
||||
className={mobileWorkspaceTab === "plan" ? "active" : ""}
|
||||
onClick={() => setMobileWorkspaceTab("plan")}
|
||||
>
|
||||
{t("planning.planPreviewTab", "Plan preview")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{renderPlanPane(runningSummary)}
|
||||
{workspaceQuestion && (
|
||||
<section className="planning-question planning-question-pane" data-testid="planning-question-pane" aria-label={t("planning.currentQuestion", "Current question")}>
|
||||
<section id="planning-question-panel" className="planning-question planning-question-pane" data-testid="planning-question-pane" aria-label={t("planning.currentQuestion", "Current question")}>
|
||||
<QuestionForm
|
||||
question={workspaceQuestion}
|
||||
initialResponse={editingQuestionId
|
||||
|
||||
@@ -187,14 +187,7 @@ describe("PlanningModeModal autosize", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps SummaryView collapsed and expanded autosize caps distinct", async () => {
|
||||
Object.defineProperty(HTMLTextAreaElement.prototype, "scrollHeight", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return 900;
|
||||
},
|
||||
});
|
||||
|
||||
it("does not expose the removed final review for an unvalidated completed session", async () => {
|
||||
mockFetchAiSession.mockResolvedValueOnce({
|
||||
id: "session-complete-1",
|
||||
type: "planning",
|
||||
@@ -228,20 +221,7 @@ describe("PlanningModeModal autosize", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
/*
|
||||
FNXC:PlanningSummaryDescription 2026-07-15-23:15:
|
||||
FN-8031 shows Markdown preview first, so autosize only applies after the Plain toggle reveals the textarea. Measure caps there rather than against a hidden textarea.
|
||||
*/
|
||||
await screen.findByText("Recovered summary description from persisted session");
|
||||
fireEvent.click(screen.getByTestId("planning-description-markdown-toggle"));
|
||||
const description = await screen.findByDisplayValue("Recovered summary description from persisted session") as HTMLTextAreaElement;
|
||||
await waitFor(() => {
|
||||
expect(description.style.height).toBe("640px");
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Expand description" }));
|
||||
await waitFor(() => {
|
||||
expect(description.style.height).toBe("800px");
|
||||
});
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("This plan is still being prepared");
|
||||
expect(screen.queryByTestId("planning-description-markdown-toggle")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,7 +69,7 @@ describe("PlanningModeModal CSS responsive action contract", () => {
|
||||
expect(embeddedRule).toMatch(/max-height\s*:\s*100%\s*;/);
|
||||
});
|
||||
|
||||
it("keeps question left and plan right on desktop, then stacks plan above question on mobile", () => {
|
||||
it("keeps question left and plan right on desktop, then uses full-view tabs on mobile", () => {
|
||||
const css = loadPlanningCss();
|
||||
const desktopRule = findRule(css, ".planning-workspace");
|
||||
expect(desktopRule).toMatch(/grid-template-areas\s*:\s*"question plan"\s*;/);
|
||||
@@ -77,8 +77,30 @@ describe("PlanningModeModal CSS responsive action contract", () => {
|
||||
expectSomeRule(css, ".planning-question-pane", /grid-area\s*:\s*question\s*;/);
|
||||
|
||||
const mobileCss = getMediaBlocks(css, MOBILE_ACTIONS_QUERY).join("\n");
|
||||
expect(findRule(mobileCss, ".planning-workspace")).toMatch(/grid-template-areas\s*:\s*"plan"\s*"question"\s*;/);
|
||||
expect(findRule(mobileCss, ".planning-workspace")).toMatch(/grid-template-rows\s*:\s*minmax\(0, 1fr\) minmax\(0, 1fr\)\s*;/);
|
||||
expect(findRule(mobileCss, ".planning-workspace--mobile-tab-question,\n .planning-workspace--mobile-tab-plan")).toMatch(/"tabs"\s*"content"/);
|
||||
expect(findRule(mobileCss, ".planning-workspace-tabs")).toMatch(/display\s*:\s*grid\s*;/);
|
||||
expect(findRule(mobileCss, ".planning-workspace--mobile-tab-question .planning-plan-pane,\n .planning-workspace--mobile-tab-plan .planning-question-pane")).toMatch(/display\s*:\s*none\s*;/);
|
||||
});
|
||||
|
||||
it("makes the history sheet full width on mobile while keeping its own scroll owner", () => {
|
||||
const css = loadPlanningCss();
|
||||
expect(findRule(css, ".planning-history-scroll")).toMatch(/overflow-y\s*:\s*auto\s*;/);
|
||||
expect(findRule(css, ".planning-history-panel")).toMatch(/width\s*:\s*min\(100%, calc\(var\(--space-2xl\) \* 15\)\)\s*;/);
|
||||
|
||||
const mobileCss = getMediaBlocks(css, MOBILE_ACTIONS_QUERY).join("\n");
|
||||
expect(findRule(mobileCss, ".planning-history-panel")).toMatch(/width\s*:\s*100%\s*;/);
|
||||
});
|
||||
|
||||
it("uses consistent full-width header controls without crowding the mobile session title", () => {
|
||||
const css = loadPlanningCss();
|
||||
expect(findRule(css, ".planning-header-controls")).toMatch(/gap\s*:\s*var\(--space-sm\)\s*;/);
|
||||
expect(findRule(css, ".planning-header-controls .btn")).toMatch(/min-height\s*:\s*calc\(var\(--space-2xl\) \+ var\(--space-sm\)\)\s*;/);
|
||||
|
||||
const mobileCss = getMediaBlocks(css, MOBILE_ACTIONS_QUERY).join("\n");
|
||||
expect(findRule(mobileCss, ".planning-modal--embedded .modal-header--embedded")).toMatch(/flex-wrap\s*:\s*wrap\s*;/);
|
||||
expect(findRule(mobileCss, ".planning-header-controls")).toMatch(/grid-template-columns\s*:\s*repeat\(2, minmax\(0, 1fr\)\)\s*;/);
|
||||
expect(findRule(mobileCss, ".planning-header-controls")).toMatch(/width\s*:\s*100%\s*;/);
|
||||
expect(findRule(mobileCss, ".planning-modal--embedded .modal-header--embedded .detail-title-row h3")).toMatch(/text-overflow\s*:\s*ellipsis\s*;/);
|
||||
});
|
||||
|
||||
it("keeps the mobile sessions list scrolling above the bottom-pinned New session footer", () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ vi.mock("../../api", () => {
|
||||
fetchAiSession: (...args: unknown[]) => mockFetchAiSession(...args), fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
|
||||
respondToPlanning: (...args: unknown[]) => mockRespondToPlanning(...args), validatePlanningSession: (...args: unknown[]) => mockValidatePlanningSession(...args), createTaskFromPlanning: (...args: unknown[]) => mockCreateTaskFromPlanning(...args),
|
||||
fetchSettings: fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }), fetchGlobalSettings: fn().mockResolvedValue({}), fetchModels: fn().mockResolvedValue([]), fetchWorkflowSteps: fn().mockResolvedValue([]), fetchBoardWorkflows: fn().mockResolvedValue({ workflows: [] }),
|
||||
startPlanning: fn(), startPlanningStreaming: (...args: unknown[]) => mockStartPlanningStreaming(...args), createPlanningDraft: (...args: unknown[]) => mockCreatePlanningDraft(...args), connectPlanningStream: fn(), rewindPlanningSession: fn(), retryPlanningSession: fn(), cancelPlanning: fn(), stopPlanningGeneration: fn(), updatePlanningSessionDraft: fn(), updatePlanningSessionTitle: fn(), startPlanningBreakdown: fn(), createTasksFromPlanning: fn(), parseConversationHistory: () => [], acquireSessionLock: fn(), releaseSessionLock: fn(), forceAcquireSessionLock: fn(), uploadAttachment: fn(), deleteAttachment: fn(), updateTask: fn(), pauseTask: fn(), unpauseTask: fn(), fetchTaskDetail: fn(), requestSpecRevision: fn(), approvePlan: fn(), rejectPlan: fn(), refineTask: fn(), deleteAiSession: fn(), refineText: fn(), getRefineErrorMessage: (error: Error) => error.message,
|
||||
startPlanning: fn(), startPlanningStreaming: (...args: unknown[]) => mockStartPlanningStreaming(...args), createPlanningDraft: (...args: unknown[]) => mockCreatePlanningDraft(...args), connectPlanningStream: fn(), rewindPlanningSession: fn(), retryPlanningSession: fn(), cancelPlanning: fn(), stopPlanningGeneration: fn(), updatePlanningSessionDraft: fn(), updatePlanningSessionTitle: fn(), startPlanningBreakdown: fn(), createTasksFromPlanning: fn(), parseConversationHistory: (raw: string) => JSON.parse(raw || "[]"), acquireSessionLock: fn(), releaseSessionLock: fn(), forceAcquireSessionLock: fn(), uploadAttachment: fn(), deleteAttachment: fn(), updateTask: fn(), pauseTask: fn(), unpauseTask: fn(), fetchTaskDetail: fn(), requestSpecRevision: fn(), approvePlan: fn(), rejectPlan: fn(), refineTask: fn(), deleteAiSession: fn(), refineText: fn(), getRefineErrorMessage: (error: Error) => error.message,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -73,6 +73,94 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
expect(document.querySelector(".planning-answered-history")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens question, answer, and expanded AI reasoning history beside Sessions", async () => {
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...base,
|
||||
status: "awaiting_input",
|
||||
currentQuestion: JSON.stringify({ id: "q-current", type: "text", question: "What should happen next?" }),
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
conversationHistory: JSON.stringify([{
|
||||
question: {
|
||||
id: "q-history",
|
||||
type: "single_select",
|
||||
question: "Which outcome matters most?",
|
||||
options: [{ id: "secure", label: "Secure defaults" }],
|
||||
},
|
||||
response: { "q-history": "secure" },
|
||||
thinkingOutput: "I updated the plan to prioritize secure defaults.",
|
||||
}]),
|
||||
inputPayload: "{}",
|
||||
});
|
||||
renderSession({});
|
||||
|
||||
const sessionsButton = await screen.findByRole("button", { name: "Sessions" });
|
||||
const historyButton = screen.getByRole("button", { name: "History" });
|
||||
expect(sessionsButton.parentElement).toContainElement(historyButton);
|
||||
fireEvent.click(historyButton);
|
||||
|
||||
expect(screen.getByRole("region", { name: "Question and answer history" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Which outcome matters most?")).toBeInTheDocument();
|
||||
expect(screen.getByText("Secure defaults")).toBeInTheDocument();
|
||||
expect(screen.getByText("I updated the plan to prioritize secure defaults.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Hide AI thinking" })).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close history" }));
|
||||
expect(screen.queryByRole("region", { name: "Question and answer history" })).toBeNull();
|
||||
await waitFor(() => expect(historyButton).toHaveFocus());
|
||||
});
|
||||
|
||||
it("creates the task directly when the user proceeds with the plan", async () => {
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...base,
|
||||
status: "awaiting_input",
|
||||
currentQuestion: JSON.stringify({ id: "q-1", type: "text", question: "Anything else?" }),
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
inputPayload: "{}",
|
||||
});
|
||||
renderSession({});
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Proceed with plan" }));
|
||||
|
||||
await waitFor(() => expect(mockValidatePlanningSession).toHaveBeenCalledWith("session-1", "project-1"));
|
||||
await waitFor(() => expect(mockCreateTaskFromPlanning).toHaveBeenCalledWith(
|
||||
"session-1",
|
||||
expect.objectContaining({ title: mockSummary.title }),
|
||||
"project-1",
|
||||
{},
|
||||
));
|
||||
expect(screen.queryByRole("heading", { name: "Review your plan" })).toBeNull();
|
||||
});
|
||||
|
||||
it("uses full-view Questions and Plan preview tabs on mobile", async () => {
|
||||
mockViewportMode.mockReturnValue("mobile");
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...base,
|
||||
status: "awaiting_input",
|
||||
currentQuestion: JSON.stringify({ id: "q-mobile", type: "text", question: "What should mobile prioritize?" }),
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
inputPayload: "{}",
|
||||
});
|
||||
renderSession({});
|
||||
|
||||
const workspace = await screen.findByTestId("planning-workspace");
|
||||
// The viewport-mode hook is mocked without changing jsdom's CSS media viewport.
|
||||
const questionsTab = screen.getByRole("tab", { name: "Questions", hidden: true });
|
||||
const planTab = screen.getByRole("tab", { name: "Plan preview", hidden: true });
|
||||
expect(questionsTab).toHaveAttribute("aria-selected", "true");
|
||||
expect(workspace).toHaveClass("planning-workspace--mobile-tab-question");
|
||||
|
||||
fireEvent.click(planTab);
|
||||
expect(planTab).toHaveAttribute("aria-selected", "true");
|
||||
expect(questionsTab).toHaveAttribute("aria-selected", "false");
|
||||
expect(workspace).toHaveClass("planning-workspace--mobile-tab-plan");
|
||||
expect(screen.getByTestId("planning-plan-pane")).toHaveTextContent("Build authentication system");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "History", hidden: true }));
|
||||
expect(screen.getByRole("region", { name: "Question and answer history" })).toBeInTheDocument();
|
||||
expect(screen.getByText("No history yet")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close history" }));
|
||||
});
|
||||
|
||||
it("keeps both panes visible under a generating-plan overlay after Next", async () => {
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...base,
|
||||
|
||||
@@ -14,8 +14,9 @@ describe("PlanningModeModal sequential layout", () => {
|
||||
it("keeps plan actions in a non-scrolling sibling footer with equal mobile columns", () => {
|
||||
const css = readFileSync(resolve(process.cwd(), "app/components/PlanningModeModal.css"), "utf8");
|
||||
expect(css).toMatch(/\.planning-actions\s*\{[^}]*flex-shrink\s*:\s*0\s*;/);
|
||||
expect(css).toMatch(/\.planning-plan-actions\s*\{[^}]*justify-content\s*:\s*flex-end\s*;/);
|
||||
expect(css).toMatch(/@media \(max-width: 1024px\)[\s\S]*?\.planning-plan-actions\s*\{[^}]*display\s*:\s*grid\s*;[^}]*grid-template-columns\s*:\s*repeat\(2, minmax\(0, 1fr\)\)\s*;[^}]*safe-area-inset-bottom/);
|
||||
expect(css).toMatch(/\.planning-plan-actions\s*\{[^}]*justify-content\s*:\s*flex-end\s*;[^}]*gap\s*:\s*var\(--space-lg\)\s*;[^}]*padding\s*:\s*var\(--space-md\) var\(--space-xl\) var\(--space-sm\)\s*;/);
|
||||
expect(css).toMatch(/@media \(max-width: 1024px\)[\s\S]*?\.planning-plan-actions\s*\{[^}]*display\s*:\s*grid\s*;[^}]*grid-template-columns\s*:\s*repeat\(2, minmax\(0, 1fr\)\)\s*;[^}]*gap\s*:\s*var\(--space-md\)\s*;[^}]*calc\(var\(--space-sm\) \+ env\(safe-area-inset-bottom\)\)/);
|
||||
expect(css).toMatch(/@media \(max-width: 1024px\)[\s\S]*?\.planning-plan-actions \.btn\s*\{[^}]*width\s*:\s*100%\s*;/);
|
||||
expect(css).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.planning-plan-actions\s*\{[^}]*gap\s*:\s*var\(--space-md\)\s*;[^}]*calc\(var\(--space-sm\) \+ env\(safe-area-inset-bottom\)\)/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user