fix(planning): make refinement session-driven
Open refinement areas on demand with multi-select and custom focus support, then consume synchronous AI responses so restored sessions can continue reliably. Rename the primary review action to Proceed with plan and preserve the responsive Markdown review layout.
This commit is contained in:
7
.changeset/improve-planning-refinement-menu.md
Normal file
7
.changeset/improve-planning-refinement-menu.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Improve Planning Mode refinement and replace Validate with Proceed with plan.
|
||||
category: fix
|
||||
dev: Combines suggested and custom focus areas and restores questions from synchronous refinement responses.
|
||||
@@ -1113,20 +1113,6 @@ margins so paragraphs and lists do not create unintended gaps.
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.planning-refine-focus {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.planning-refine-focus legend {
|
||||
margin-bottom: var(--space-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.planning-confirm-answer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1210,9 +1196,9 @@ margins so paragraphs and lists do not create unintended gaps.
|
||||
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-20-17:00:
|
||||
The review checkpoint renders the same canonical Markdown that Validate persists as
|
||||
plan.md. Keep the document and refinement controls inside the sole scroll owner while
|
||||
the sibling action row remains reachable at the bottom of bounded desktop and mobile panes.
|
||||
The review checkpoint renders the same canonical Markdown that Proceed persists as
|
||||
plan.md. Keep the document inside the sole scroll owner while the sibling action row and
|
||||
its refinement menu remain reachable at the bottom of bounded desktop and mobile panes.
|
||||
*/
|
||||
.planning-plan-scroll {
|
||||
align-items: center;
|
||||
@@ -1224,8 +1210,7 @@ the sibling action row remains reachable at the bottom of bounded desktop and mo
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.planning-plan-document,
|
||||
.planning-plan-scroll > .planning-refine-focus {
|
||||
.planning-plan-document {
|
||||
width: min(100%, calc(var(--space-2xl) * 24));
|
||||
}
|
||||
|
||||
@@ -1260,19 +1245,6 @@ the sibling action row remains reachable at the bottom of bounded desktop and mo
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.planning-plan-scroll > .planning-refine-focus {
|
||||
padding: var(--space-xl);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 0 0 var(--btn-border-width) color-mix(in srgb, var(--border) 75%, transparent);
|
||||
}
|
||||
|
||||
.planning-plan-scroll > .planning-refine-focus legend {
|
||||
padding-inline: var(--space-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.planning-summary-qa-disclosure {
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -1468,6 +1440,7 @@ the sibling action row remains reachable at the bottom of bounded desktop and mo
|
||||
}
|
||||
|
||||
.planning-plan-actions {
|
||||
position: relative;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-lg) var(--space-xl);
|
||||
@@ -1478,6 +1451,118 @@ the sibling action row remains reachable at the bottom of bounded desktop and mo
|
||||
min-width: calc(var(--space-2xl) * 4);
|
||||
}
|
||||
|
||||
.planning-refine-menu {
|
||||
position: absolute;
|
||||
right: var(--space-xl);
|
||||
bottom: calc(100% + var(--space-sm));
|
||||
z-index: var(--z-popover);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
width: min(calc(var(--space-2xl) * 14), calc(100vw - var(--space-2xl)));
|
||||
max-height: min(70dvh, calc(var(--space-2xl) * 16));
|
||||
padding: var(--space-xl);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.planning-refine-menu-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.planning-refine-menu-header h4,
|
||||
.planning-refine-menu-header p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.planning-refine-menu-header h4 {
|
||||
font-size: var(--font-size-base);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.planning-refine-menu-header p,
|
||||
.planning-refine-menu-custom > span {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.planning-refine-menu-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.planning-refine-menu-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-height: calc(var(--space-2xl) + var(--space-md));
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--card);
|
||||
box-shadow: 0 0 0 var(--btn-border-width) color-mix(in srgb, var(--border) 80%, transparent);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.planning-refine-menu-option:hover {
|
||||
background: var(--card-hover);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.planning-refine-menu-option:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.planning-refine-menu-option:has(input:checked) {
|
||||
background: color-mix(in srgb, var(--todo) 12%, var(--surface));
|
||||
box-shadow: 0 0 0 var(--btn-border-width) var(--todo);
|
||||
}
|
||||
|
||||
.planning-refine-menu-option input {
|
||||
flex-shrink: 0;
|
||||
accent-color: var(--todo);
|
||||
}
|
||||
|
||||
.planning-refine-menu-option span {
|
||||
min-width: 0;
|
||||
color: var(--text);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.planning-refine-menu-custom {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.planning-refine-menu-custom textarea {
|
||||
min-height: calc(var(--space-2xl) * 2);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.planning-refine-menu-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.planning-refine-menu-actions .btn {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.planning-summary-actions-right {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
@@ -1491,10 +1576,6 @@ the sibling action row remains reachable at the bottom of bounded desktop and mo
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.planning-plan-scroll > .planning-refine-focus {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.planning-plan-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -1507,6 +1588,18 @@ the sibling action row remains reachable at the bottom of bounded desktop and mo
|
||||
min-width: 0;
|
||||
min-height: calc(var(--space-2xl) + var(--space-md));
|
||||
}
|
||||
|
||||
.planning-refine-menu {
|
||||
right: var(--space-lg);
|
||||
left: var(--space-lg);
|
||||
width: auto;
|
||||
max-height: min(65dvh, calc(var(--space-2xl) * 14));
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.planning-refine-menu-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -559,11 +559,44 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
const isSessionListMode = showSessionList || (isCompactInterview && !mobileShowDetail);
|
||||
// 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 [refineFocus, setRefineFocus] = useState("");
|
||||
const [customRefineFocus, setCustomRefineFocus] = useState<string | null>(null);
|
||||
const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false);
|
||||
const [selectedRefineFocuses, setSelectedRefineFocuses] = useState<string[]>([]);
|
||||
const [customRefineFocus, setCustomRefineFocus] = useState("");
|
||||
const refineMenuRef = useRef<HTMLDivElement>(null);
|
||||
const refineTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const { addToast } = useToast();
|
||||
const { pushNav } = useNavigationHistoryContext();
|
||||
|
||||
const combinedRefineFocus = useMemo(
|
||||
() => [...selectedRefineFocuses, customRefineFocus.trim()].filter(Boolean).join(", "),
|
||||
[customRefineFocus, selectedRefineFocuses],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRefineMenuOpen) return;
|
||||
refineMenuRef.current?.focus();
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (!refineMenuRef.current?.contains(target) && !refineTriggerRef.current?.contains(target)) {
|
||||
setIsRefineMenuOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.stopImmediatePropagation();
|
||||
setIsRefineMenuOpen(false);
|
||||
refineTriggerRef.current?.focus();
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isRefineMenuOpen]);
|
||||
|
||||
/*
|
||||
FNXC:Planning 2026-06-23-02:00:
|
||||
Resizable Planning sidebar — pointer-drag + arrow-key resize with localStorage persistence, mirroring MissionManager.handleSidebarResizeStart/handleSidebarResizeKeyDown. Width is clamped to PLANNING_SIDEBAR_MIN/MAX and applied as an inline width on the sidebar <aside>. Disabled on mobile where the sidebar stacks full-width.
|
||||
@@ -2235,19 +2268,35 @@ 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") return;
|
||||
if (view.type !== "plan_review" || !combinedRefineFocus) return;
|
||||
setError(null);
|
||||
setGenerationActivity("question");
|
||||
setIsRefineMenuOpen(false);
|
||||
setView({ type: "loading" });
|
||||
try {
|
||||
await respondToPlanning(view.session.sessionId, { refine: true, ...(refineFocus.trim() ? { focus: refineFocus.trim() } : {}) }, projectId);
|
||||
setRefineFocus("");
|
||||
setCustomRefineFocus(null);
|
||||
const response = await respondToPlanning(view.session.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;
|
||||
runningSummaryRef.current = nextSummary;
|
||||
setRunningSummary(nextSummary);
|
||||
if (responseQuestion) {
|
||||
setView({
|
||||
type: "question",
|
||||
session: {
|
||||
sessionId: view.session.sessionId,
|
||||
currentQuestion: normalizeQuestionOptions(responseQuestion),
|
||||
summary: nextSummary,
|
||||
},
|
||||
});
|
||||
}
|
||||
setSelectedRefineFocuses([]);
|
||||
setCustomRefineFocus("");
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err) || t("planning.failedSubmitResponse", "Failed to refine plan"));
|
||||
setView({ type: "plan_review", session: view.session, summary: view.summary });
|
||||
}
|
||||
}, [projectId, refineFocus, t, view]);
|
||||
}, [combinedRefineFocus, projectId, t, view]);
|
||||
|
||||
const handleValidatePlan = useCallback(async () => {
|
||||
if (view.type !== "plan_review" || validateCreateInFlightRef.current) return;
|
||||
@@ -2887,65 +2936,83 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
testId="planning-plan-markdown"
|
||||
/>
|
||||
</article>
|
||||
<fieldset className="planning-refine-focus" data-testid="planning-refine-focus">
|
||||
<legend>{t("planning.refineFocus", "Focus the next question")}</legend>
|
||||
<div className="planning-radio-group" role="radiogroup">
|
||||
{(view.summary.suggestedRefinements ?? []).map((focus) => (
|
||||
<label key={focus} className="planning-option planning-option--radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="planning-refine-focus"
|
||||
value={focus}
|
||||
checked={customRefineFocus === null && refineFocus === focus}
|
||||
onChange={() => {
|
||||
setCustomRefineFocus(null);
|
||||
setRefineFocus(focus);
|
||||
}}
|
||||
/>
|
||||
<span className="planning-option-label">{focus}</span>
|
||||
</label>
|
||||
))}
|
||||
<label className="planning-option planning-option--radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="planning-refine-focus"
|
||||
value={PLANNING_OTHER_OPTION_ID}
|
||||
checked={customRefineFocus !== null}
|
||||
onChange={() => {
|
||||
setCustomRefineFocus("");
|
||||
setRefineFocus("");
|
||||
}}
|
||||
/>
|
||||
<span className="planning-option-label">{t("planning.writeOwnFocus", "Write your own focus")}</span>
|
||||
</label>
|
||||
{customRefineFocus !== null && (
|
||||
<input
|
||||
className="input"
|
||||
autoFocus
|
||||
value={customRefineFocus}
|
||||
onChange={(event) => {
|
||||
setCustomRefineFocus(event.target.value);
|
||||
setRefineFocus(event.target.value);
|
||||
}}
|
||||
placeholder={t("planning.refineFocusPlaceholder", "Describe what the next question should focus on")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div
|
||||
className="planning-actions planning-summary-actions planning-plan-actions"
|
||||
data-testid="planning-plan-actions"
|
||||
>
|
||||
{isRefineMenuOpen && (
|
||||
<div
|
||||
id="planning-refine-menu"
|
||||
ref={refineMenuRef}
|
||||
className="planning-refine-menu"
|
||||
data-testid="planning-refine-menu"
|
||||
role="dialog"
|
||||
aria-label={t("planning.chooseRefinementAreas", "Choose areas to refine")}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="planning-refine-menu-header">
|
||||
<h4>{t("planning.refineQuestion", "What should the next question focus on?")}</h4>
|
||||
<p>{t("planning.refineQuestionHint", "Choose one or more areas, or describe your own.")}</p>
|
||||
</div>
|
||||
<div className="planning-refine-menu-options">
|
||||
{(view.summary.suggestedRefinements ?? []).map((focus) => (
|
||||
<label key={focus} className="planning-refine-menu-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRefineFocuses.includes(focus)}
|
||||
onChange={() => setSelectedRefineFocuses((previous) => previous.includes(focus)
|
||||
? previous.filter((item) => item !== focus)
|
||||
: [...previous, focus])}
|
||||
/>
|
||||
<span>{focus}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="planning-refine-menu-custom">
|
||||
<span>{t("planning.otherRefineFocus", "Or describe another focus")}</span>
|
||||
<textarea
|
||||
className="input"
|
||||
value={customRefineFocus}
|
||||
onChange={(event) => setCustomRefineFocus(event.target.value)}
|
||||
placeholder={t("planning.refineFocusPlaceholder", "Describe what the next question should focus on")}
|
||||
rows={2}
|
||||
/>
|
||||
</label>
|
||||
<div className="planning-refine-menu-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
setIsRefineMenuOpen(false);
|
||||
setSelectedRefineFocuses([]);
|
||||
setCustomRefineFocus("");
|
||||
refineTriggerRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" disabled={!combinedRefineFocus} onClick={() => void handleRefineFromPlan()}>
|
||||
{t("planning.askNextQuestion", "Ask next question")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
ref={refineTriggerRef}
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={refineFocus.trim().length === 0}
|
||||
onClick={() => void handleRefineFromPlan()}
|
||||
aria-expanded={isRefineMenuOpen}
|
||||
aria-controls={isRefineMenuOpen ? "planning-refine-menu" : undefined}
|
||||
onClick={() => {
|
||||
setSelectedRefineFocuses([]);
|
||||
setCustomRefineFocus("");
|
||||
setIsRefineMenuOpen((open) => !open);
|
||||
}}
|
||||
>
|
||||
{t("planning.refine", "Refine")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void handleValidatePlan()}>{t("planning.validatePlan", "Validate")}</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void handleValidatePlan()}>{t("planning.proceedWithPlan", "Proceed with plan")}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ describe("PlanningModeModal initial surface", () => {
|
||||
it("keeps Sessions as the single escape hatch while the active interview is sequential", () => {
|
||||
const source = readFileSync(resolve(process.cwd(), "app/components/PlanningModeModal.tsx"), "utf8");
|
||||
expect(source).toContain('data-testid="planning-plan-review"');
|
||||
expect(source).toContain('data-testid="planning-refine-focus"');
|
||||
expect(source).toContain('data-testid="planning-refine-menu"');
|
||||
expect(source).toContain('data-testid="planning-create-retry"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,14 +53,10 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
expect(screen.getByText("Change the authentication API")).toBeInTheDocument();
|
||||
expect(screen.getByText("Acceptance criteria")).toBeInTheDocument();
|
||||
expect(screen.getByText("Refresh preserves generation")).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: "Security boundaries" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: "Rollout strategy" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: "Failure recovery" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: "Accessibility" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: "Observability" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: "Write your own focus" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Refine" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Validate" })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("planning-refine-menu")).toBeNull();
|
||||
expect(screen.queryByRole("checkbox", { name: "Security boundaries" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Refine" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "Proceed with plan" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Sessions" })).toBeInTheDocument();
|
||||
const scrollRegion = screen.getByTestId("planning-plan-scroll");
|
||||
const actionBar = screen.getByTestId("planning-plan-actions");
|
||||
@@ -69,14 +65,31 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
expect(document.querySelector(".planning-running-plan")).toBeNull();
|
||||
expect(document.querySelector(".planning-answered-history")).toBeNull();
|
||||
});
|
||||
it("sends a model-suggested focus when Refine requests the next question", async () => {
|
||||
it("opens a multi-select refinement menu and sends every selected or custom focus", async () => {
|
||||
mockFetchAiSession.mockResolvedValue({ ...base, status: "awaiting_input", currentQuestion: null, result: JSON.stringify(summaryWithRefinements), inputPayload: "{}" });
|
||||
mockRespondToPlanning.mockResolvedValue({}); renderSession({});
|
||||
fireEvent.click(await screen.findByRole("radio", { name: "Security boundaries" }));
|
||||
expect(screen.getByRole("button", { name: "Refine" })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refine" }));
|
||||
await waitFor(() => expect(mockRespondToPlanning).toHaveBeenCalledWith("session-1", { refine: true, focus: "Security boundaries" }, "project-1"));
|
||||
expect(screen.getByText("Generating next question…")).toBeInTheDocument();
|
||||
mockRespondToPlanning.mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
currentQuestion: {
|
||||
id: "q-refine",
|
||||
type: "single_select",
|
||||
question: "Which migration risk should come first?",
|
||||
options: [
|
||||
{ id: "data", label: "Data integrity" },
|
||||
{ id: "rollout", label: "Rollout safety" },
|
||||
],
|
||||
},
|
||||
summary: summaryWithRefinements,
|
||||
});
|
||||
renderSession({});
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Refine" }));
|
||||
expect(screen.getByRole("dialog", { name: "Choose areas to refine" })).toBeInTheDocument();
|
||||
expect(screen.getByText("What should the next question focus on?")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Security boundaries" }));
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Observability" }));
|
||||
fireEvent.change(screen.getByLabelText("Or describe another focus"), { target: { value: "Migration sequencing" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Ask next question" }));
|
||||
await waitFor(() => expect(mockRespondToPlanning).toHaveBeenCalledWith("session-1", { refine: true, focus: "Security boundaries, Observability, Migration sequencing" }, "project-1"));
|
||||
expect(await screen.findByText("Which migration risk should come first?")).toBeInTheDocument();
|
||||
});
|
||||
it("restores the updating-plan progress state after refresh", async () => {
|
||||
mockFetchAiSession.mockResolvedValue({ ...base, status: "generating", currentQuestion: null, result: JSON.stringify(summaryWithRefinements), inputPayload: JSON.stringify({ generationPurpose: "plan_update" }) });
|
||||
@@ -110,18 +123,23 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
renderSession({});
|
||||
expect(await screen.findByText("What to change")).toBeInTheDocument();
|
||||
expect(screen.getByText("Acceptance criteria")).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: "Security boundaries" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: "Observability" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: "Write your own focus" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("checkbox", { name: "Security boundaries" })).toBeNull();
|
||||
const actionBar = screen.getByTestId("planning-plan-actions");
|
||||
expect(screen.getByTestId("planning-plan-scroll")).not.toContainElement(actionBar);
|
||||
expect(actionBar).toContainElement(screen.getByRole("button", { name: "Refine" }));
|
||||
expect(actionBar).toContainElement(screen.getByRole("button", { name: "Validate" }));
|
||||
expect(actionBar).toContainElement(screen.getByRole("button", { name: "Proceed with plan" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refine" }));
|
||||
expect(screen.getByRole("dialog", { name: "Choose areas to refine" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("checkbox", { name: "Security boundaries" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("checkbox", { name: "Observability" })).toBeInTheDocument();
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog", { name: "Choose areas to refine" })).toBeNull();
|
||||
expect(screen.getByTestId("planning-plan-review")).toBeInTheDocument();
|
||||
});
|
||||
it("restores a validated unlinked session to create-only retry", async () => {
|
||||
mockFetchAiSession.mockResolvedValue({ ...base, status: "complete", currentQuestion: null, result: JSON.stringify(mockSummary), inputPayload: JSON.stringify({ validated: true }) });
|
||||
renderSession({});
|
||||
expect(await screen.findByTestId("planning-create-retry")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Validate" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Proceed with plan" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,20 @@ import { NavigationHistoryProvider } from "./hooks/useNavigationHistory";
|
||||
const summary = {
|
||||
title: "Adaptive planning workflow",
|
||||
description: "An evolving **operator-ready** plan assembled from the interview answers.",
|
||||
proposedChanges: ["Render the canonical plan as Markdown", "Keep review actions reachable while the plan scrolls"],
|
||||
proposedChanges: [
|
||||
"Render the canonical plan as Markdown",
|
||||
"Keep review actions reachable while the plan scrolls",
|
||||
"Generate the initial plan in a real AI session",
|
||||
"Open refinement areas only from the Refine action",
|
||||
"Allow multiple suggested refinement areas",
|
||||
"Accept an operator-authored refinement focus",
|
||||
"Ask one focused question after refinement",
|
||||
"Return to plan review after each answer",
|
||||
"Preserve the plan document while refinement is open",
|
||||
"Keep the refinement menu usable on narrow screens",
|
||||
"Expose a clear Proceed with plan action",
|
||||
"Persist the reviewed Markdown as the task plan",
|
||||
],
|
||||
acceptanceCriteria: ["Markdown structure is visible", "Mobile actions remain at the bottom of the planning pane"],
|
||||
suggestedSize: "M",
|
||||
priority: "normal",
|
||||
@@ -51,7 +64,6 @@ const questions = [
|
||||
},
|
||||
];
|
||||
|
||||
let answerCount = 0;
|
||||
const streams = new Set<MockEventSource>();
|
||||
|
||||
class MockEventSource {
|
||||
@@ -87,23 +99,31 @@ function emitTurn(questionIndex: number): void {
|
||||
}, 20);
|
||||
}
|
||||
|
||||
function emitSummary(): void {
|
||||
setTimeout(() => {
|
||||
streams.forEach((stream) => stream.emit("summary", summary));
|
||||
}, 20);
|
||||
}
|
||||
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = async (input, init = {}) => {
|
||||
const url = String(input);
|
||||
const method = init.method ?? "GET";
|
||||
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
||||
if (url.includes("/planning/start-streaming") && method === "POST") {
|
||||
answerCount = 0;
|
||||
emitTurn(0);
|
||||
emitSummary();
|
||||
return json({ sessionId: "planning-browser-e2e" });
|
||||
}
|
||||
if (url.includes("/planning/respond") && method === "POST") {
|
||||
answerCount += 1;
|
||||
emitTurn(answerCount);
|
||||
return json({ sessionId: "planning-browser-e2e", currentQuestion: questions[answerCount], summary });
|
||||
const body = typeof init.body === "string" ? JSON.parse(init.body) as { responses?: { refine?: boolean } } : {};
|
||||
if (body.responses?.refine) {
|
||||
emitTurn(1);
|
||||
return json({ sessionId: "planning-browser-e2e", currentQuestion: questions[1], summary });
|
||||
}
|
||||
emitSummary();
|
||||
return json({ sessionId: "planning-browser-e2e", currentQuestion: null, summary });
|
||||
}
|
||||
if (url.includes("/planning/planning-browser-e2e/back") && method === "POST") {
|
||||
answerCount = 0;
|
||||
emitTurn(0);
|
||||
return json({ currentQuestion: questions[0], summary, history: [] });
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ describe.runIf(executablePath)("Planning Mode browser E2E", () => {
|
||||
await server.pluginContainer.close();
|
||||
}, 10_000);
|
||||
|
||||
it("keeps the adaptive question and edit-answer loop working", async () => {
|
||||
it("starts an AI plan session and asks a focused question only after Refine", async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
page.on("console", (event) => console.log(`[planning-browser-e2e] ${event.text?.() ?? ""}`));
|
||||
page.on("pageerror", (event) => console.error(`[planning-browser-e2e] ${event.message ?? ""}`));
|
||||
@@ -86,19 +86,17 @@ describe.runIf(executablePath)("Planning Mode browser E2E", () => {
|
||||
|
||||
await page.getByLabel("What do you want to build?").fill("Make Planning Mode adaptive");
|
||||
await page.getByRole("button", { name: "Start Planning" }).click();
|
||||
await expectVisible(page.getByText("Which user outcome matters most?"));
|
||||
await expectVisible(page.getByRole("heading", { name: "Adaptive planning workflow" }));
|
||||
await expectVisible(page.getByRole("button", { name: "Proceed with plan" }));
|
||||
expect(await page.getByText("Who should receive this first?").isVisible()).toBe(false);
|
||||
|
||||
await page.getByLabel("Speed").check();
|
||||
await page.getByRole("button", { name: "Next question" }).click();
|
||||
await expectVisible(page.getByText("Who should receive this first?"));
|
||||
await expectVisible(page.getByText("Which user outcome matters most?"));
|
||||
expect(await page.getByRole("button", { name: "Create Single Task" }).isVisible()).toBe(false);
|
||||
|
||||
await page.getByRole("button", { name: /Edit answer for Which user outcome matters most/ }).click();
|
||||
await expectVisible(page.getByText("Which user outcome matters most?"));
|
||||
await page.getByLabel("Depth").check();
|
||||
await page.getByRole("button", { name: "Next question" }).click();
|
||||
await page.getByRole("button", { name: "Refine" }).click();
|
||||
await page.getByLabel("Security boundaries").check();
|
||||
await page.getByRole("button", { name: "Ask next question" }).click();
|
||||
await expectVisible(page.getByText("Who should receive this first?"));
|
||||
await page.getByLabel("Operators").check();
|
||||
await page.getByRole("button", { name: "Continue to plan" }).click();
|
||||
await expectVisible(page.getByRole("button", { name: "Proceed with plan" }));
|
||||
await page.close();
|
||||
}, 30_000);
|
||||
|
||||
@@ -107,7 +105,7 @@ describe.runIf(executablePath)("Planning Mode browser E2E", () => {
|
||||
const page = await browser.newPage({ viewport });
|
||||
await page.goto(`${baseUrl}app/planning-browser-e2e-fixture.html?surface=plan-review`);
|
||||
await expectVisible(page.getByRole("heading", { name: "Adaptive planning workflow" }));
|
||||
await expectVisible(page.getByRole("button", { name: "Validate" }));
|
||||
await expectVisible(page.getByRole("button", { name: "Proceed with plan" }));
|
||||
|
||||
const layout = await page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("[data-testid='planning-plan-review']")!;
|
||||
@@ -140,11 +138,15 @@ describe.runIf(executablePath)("Planning Mode browser E2E", () => {
|
||||
markdownRendered: true,
|
||||
});
|
||||
if (viewport.width > 1024) {
|
||||
await page.getByLabel("Security boundaries").check();
|
||||
await page.getByRole("button", { name: "Refine" }).click();
|
||||
await expectVisible(page.getByRole("dialog", { name: "Choose areas to refine" }));
|
||||
await page.getByLabel("Security boundaries").check();
|
||||
await page.getByLabel("Observability").check();
|
||||
await page.getByLabel("Or describe another focus").fill("Migration sequencing");
|
||||
await page.getByRole("button", { name: "Ask next question" }).click();
|
||||
await expectVisible(page.getByText("Who should receive this first?"));
|
||||
} else {
|
||||
await page.getByRole("button", { name: "Validate" }).click();
|
||||
await page.getByRole("button", { name: "Proceed with plan" }).click();
|
||||
for (let attempt = 0; attempt < 20 && await page.evaluate(() => document.body.dataset.createdTask) !== "FN-BROWSER"; attempt += 1) {
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
|
||||
@@ -143,6 +143,7 @@ describe("reactive Planning Mode question contract", () => {
|
||||
expect(prompt).toMatch(/plan in Markdown/i);
|
||||
}
|
||||
expect(prompts.at(-1)).toMatch(/without asking another question/i);
|
||||
expect(PLANNING_SYSTEM_PROMPT).toMatch(/Proceed with plan serializes the plan as plan\.md/i);
|
||||
});
|
||||
|
||||
it("repairs malformed select options and appends one localized Other option", () => {
|
||||
|
||||
@@ -233,7 +233,7 @@ async function ensureNtfyHelpersReady(): Promise<void> {
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-20-00:55:
|
||||
Planning Mode is user-terminated: each answered turn must produce one consequential, novel question with alternatives and trade-offs.
|
||||
The model may update the running plan but must never infer completion; only the visible Validate plan action can make a session terminal.
|
||||
The model may update the running plan but must never infer completion; only the visible Proceed with plan action can make a session terminal.
|
||||
*/
|
||||
/** Planning system prompt for the AI agent */
|
||||
export const PLANNING_SYSTEM_PROMPT = `## Planning Mode interaction adapter
|
||||
@@ -244,7 +244,7 @@ Start by producing a concrete initial plan for review without asking a question.
|
||||
|
||||
For a refine turn respond only with JSON: {"type":"question","data":{"id":"unique-id","type":"single_select|multi_select","question":"...","description":"...","options":[{"id":"option-a","label":"...","description":"...","pros":["..."],"cons":["..."]},{"id":"option-b","label":"...","description":"...","pros":["..."],"cons":["..."]},{"id":"other","label":"...","isOther":true}],"runningPlan":{"title":"...","description":"...","proposedChanges":["specific change"],"acceptanceCriteria":["observable outcome"],"suggestedSize":"S|M|L","priority":"normal","suggestedDependencies":[],"keyDeliverables":["concrete work item"],"suggestedRefinements":["next focus 1","next focus 2"]}}}. For an initial-plan or answer-update turn respond only with {"type":"complete","data":{"title":"...","description":"...","proposedChanges":["specific change"],"acceptanceCriteria":["observable outcome"],"suggestedSize":"S|M|L","priority":"normal","suggestedDependencies":[],"keyDeliverables":["concrete work item"],"suggestedRefinements":["next focus 1","next focus 2"]}}.
|
||||
|
||||
Every turn must include the running-plan fields: only title, description, concrete proposedChanges, observable acceptanceCriteria, suggestedSize, optional priority, suggestedDependencies, concrete keyDeliverables, and concise suggestedRefinements informed by the idea and answers so far. Include every distinct, high-value unresolved refinement area; do not cap the list at three. Never use interview question text as a deliverable. Do not put PROMPT.md sections (Mission, Before → After, Steps, File Scope, Review Level, Completion Criteria, or Do NOT) in runningPlan or free text: triage writes PROMPT.md only after Validate. Validate serializes the plan as plan.md without priority or suggestedRefinements; priority remains a task field. Every question must provide at least two alternatives, each with non-empty pros and cons, plus exactly one Other/write-your-own option. Write every label, option, and Other label in the language of the user's original input. Incorporate free-text Other answers verbatim as steering context for the following question.`;
|
||||
Every turn must include the running-plan fields: only title, description, concrete proposedChanges, observable acceptanceCriteria, suggestedSize, optional priority, suggestedDependencies, concrete keyDeliverables, and concise suggestedRefinements informed by the idea and answers so far. Include every distinct, high-value unresolved refinement area; do not cap the list at three. Never use interview question text as a deliverable. Do not put PROMPT.md sections (Mission, Before → After, Steps, File Scope, Review Level, Completion Criteria, or Do NOT) in runningPlan or free text: triage writes PROMPT.md only after the operator proceeds with the plan. Proceed with plan serializes the plan as plan.md without priority or suggestedRefinements; priority remains a task field. Every question must provide at least two alternatives, each with non-empty pros and cons, plus exactly one Other/write-your-own option. Write every label, option, and Other label in the language of the user's original input. Incorporate free-text Other answers verbatim as steering context for the following question.`;
|
||||
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-20-14:30:
|
||||
|
||||
Reference in New Issue
Block a user