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:
gsxdsm
2026-07-20 21:20:10 -07:00
parent 34074e9d04
commit f221d2bc4d
7 changed files with 515 additions and 80 deletions

View File

@@ -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.

View File

@@ -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%;

View File

@@ -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

View File

@@ -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();
});
});

View File

@@ -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", () => {

View File

@@ -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,

View File

@@ -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\)\)/);
});
});