fix(planning): polish markdown plan review
Render the canonical plan as sanitized Markdown and keep responsive review actions reachable outside the scroll owner. Require Markdown-oriented planning output and preserve stable plan.md list round-trips.
This commit is contained in:
7
.changeset/polish-planning-markdown-review.md
Normal file
7
.changeset/polish-planning-markdown-review.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Improve Planning Mode with scrollable Markdown plans and mobile bottom actions.
|
||||||
|
category: fix
|
||||||
|
dev: Uses the canonical plan.md formatter for the sanitized review preview and pins actions outside its scroll owner.
|
||||||
@@ -32,4 +32,22 @@ describe("Planning Mode plan.md", () => {
|
|||||||
suggestedDependencies: ["FN-12"],
|
suggestedDependencies: ["FN-12"],
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps Markdown-flavored multiline list values as single round-trip items", () => {
|
||||||
|
const formatted = formatPlanningPlanMd({
|
||||||
|
title: "Stable lists",
|
||||||
|
description: "Keep structured plan fields parseable.",
|
||||||
|
proposedChanges: ["Render Markdown\n - preserve the canonical section"],
|
||||||
|
acceptanceCriteria: ["Review shows **formatted** content\nwithout splitting the criterion"],
|
||||||
|
suggestedSize: "S",
|
||||||
|
suggestedDependencies: [],
|
||||||
|
keyDeliverables: ["A stable\nplan.md"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsePlanningPlanMd(formatted)).toEqual(expect.objectContaining({
|
||||||
|
proposedChanges: ["Render Markdown - preserve the canonical section"],
|
||||||
|
acceptanceCriteria: ["Review shows **formatted** content without splitting the criterion"],
|
||||||
|
keyDeliverables: ["A stable plan.md"],
|
||||||
|
}));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,26 +1,11 @@
|
|||||||
import type { PlanningSummary } from "./types.js";
|
import type { PlanningSummary } from "./types.js";
|
||||||
|
export { formatPlanningPlanMd } from "./types.js";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:PlanningMode 2026-07-20-12:00:
|
FNXC:PlanningMode 2026-07-20-12:00:
|
||||||
FN-8441 makes plan.md Planning Mode's lean, durable operator product. Triage alone
|
FN-8441 makes plan.md Planning Mode's lean, durable operator product. Triage alone
|
||||||
expands it into PROMPT.md; priority remains a task-row field and is never serialized here.
|
expands it into PROMPT.md; priority remains a task-row field and is never serialized here.
|
||||||
*/
|
*/
|
||||||
export function formatPlanningPlanMd(summary: PlanningSummary): string {
|
|
||||||
const list = (items: string[] | undefined) => items && items.length > 0
|
|
||||||
? items.map((item) => `- ${item}`).join("\n")
|
|
||||||
: "_None_";
|
|
||||||
const proposedChanges = list(summary.proposedChanges);
|
|
||||||
const acceptanceCriteria = list(summary.acceptanceCriteria);
|
|
||||||
const dependencies = summary.suggestedDependencies.length > 0
|
|
||||||
? summary.suggestedDependencies.map((dependency) => `- ${dependency}`).join("\n")
|
|
||||||
: "_None_";
|
|
||||||
const deliverables = summary.keyDeliverables.length > 0
|
|
||||||
? summary.keyDeliverables.map((deliverable) => `- ${deliverable}`).join("\n")
|
|
||||||
: "_None_";
|
|
||||||
|
|
||||||
return `# ${summary.title}\n\n${summary.description}\n\n## What to change\n${proposedChanges}\n\n## Acceptance criteria\n${acceptanceCriteria}\n\n## Size\n${summary.suggestedSize}\n\n## Suggested dependencies\n${dependencies}\n\n## Key deliverables\n${deliverables}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:PlanningMode 2026-07-20-16:00:
|
FNXC:PlanningMode 2026-07-20-16:00:
|
||||||
A plan description may itself mention plan.md headings. Parse only the final, exact
|
A plan description may itself mention plan.md headings. Parse only the final, exact
|
||||||
|
|||||||
@@ -5675,6 +5675,25 @@ export interface PlanningSummary {
|
|||||||
suggestedRefinements?: string[];
|
suggestedRefinements?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PlanningMode 2026-07-20-17:15:
|
||||||
|
This pure formatter lives on the dashboard's browser-safe core surface so plan review
|
||||||
|
and server persistence share one canonical Markdown representation without widening
|
||||||
|
the client bundle to Node-only core modules.
|
||||||
|
*/
|
||||||
|
export function formatPlanningPlanMd(summary: PlanningSummary): string {
|
||||||
|
const normalizeListItem = (item: string) => item.replace(/\s+/g, " ").trim();
|
||||||
|
const list = (items: string[] | undefined) => items && items.length > 0
|
||||||
|
? items.map((item) => `- ${normalizeListItem(item)}`).join("\n")
|
||||||
|
: "_None_";
|
||||||
|
const proposedChanges = list(summary.proposedChanges);
|
||||||
|
const acceptanceCriteria = list(summary.acceptanceCriteria);
|
||||||
|
const dependencies = list(summary.suggestedDependencies);
|
||||||
|
const deliverables = list(summary.keyDeliverables);
|
||||||
|
|
||||||
|
return `# ${summary.title}\n\n${summary.description}\n\n## What to change\n${proposedChanges}\n\n## Acceptance criteria\n${acceptanceCriteria}\n\n## Size\n${summary.suggestedSize}\n\n## Suggested dependencies\n${dependencies}\n\n## Key deliverables\n${deliverables}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Response from planning endpoints - either a question or the final summary */
|
/** Response from planning endpoints - either a question or the final summary */
|
||||||
export type PlanningResponse =
|
export type PlanningResponse =
|
||||||
| { type: "question"; data: PlanningQuestion }
|
| { type: "question"; data: PlanningQuestion }
|
||||||
|
|||||||
@@ -1186,7 +1186,7 @@ margins so paragraphs and lists do not create unintended gaps.
|
|||||||
.planning-summary {
|
.planning-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: var(--space-xl);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -1205,7 +1205,72 @@ margins so paragraphs and lists do not create unintended gaps.
|
|||||||
}
|
}
|
||||||
|
|
||||||
.planning-summary-scroll {
|
.planning-summary-scroll {
|
||||||
gap: 20px;
|
gap: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
.planning-plan-scroll {
|
||||||
|
align-items: center;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
background: color-mix(in srgb, var(--card) 45%, var(--surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-plan-review {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-plan-document,
|
||||||
|
.planning-plan-scroll > .planning-refine-focus {
|
||||||
|
width: min(100%, calc(var(--space-2xl) * 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-plan-document {
|
||||||
|
padding: var(--space-2xl);
|
||||||
|
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),
|
||||||
|
var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-plan-markdown {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1.7;
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-plan-markdown h1,
|
||||||
|
.planning-plan-markdown h2,
|
||||||
|
.planning-plan-markdown h3,
|
||||||
|
.planning-plan-markdown h4 {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-plan-markdown h1 {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-plan-markdown h2 {
|
||||||
|
margin-top: var(--space-xl);
|
||||||
|
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 {
|
.planning-summary-qa-disclosure {
|
||||||
@@ -1402,6 +1467,17 @@ margins so paragraphs and lists do not create unintended gaps.
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.planning-plan-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: var(--space-lg) var(--space-xl);
|
||||||
|
background: color-mix(in srgb, var(--surface) 94%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-plan-actions .btn {
|
||||||
|
min-width: calc(var(--space-2xl) * 4);
|
||||||
|
}
|
||||||
|
|
||||||
.planning-summary-actions-right {
|
.planning-summary-actions-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
@@ -1410,6 +1486,29 @@ margins so paragraphs and lists do not create unintended gaps.
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.planning-plan-document {
|
||||||
|
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));
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-md) var(--space-lg) calc(var(--space-lg) + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-plan-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: calc(var(--space-2xl) + var(--space-md));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:PlanningMode 2026-06-25-09:20:
|
FNXC:PlanningMode 2026-06-25-09:20:
|
||||||
Tablet embedded Planning keeps the desktop two-pane shell, so the summary footer must absorb the narrower detail pane instead of relying on the mobile full-width stack. Allow the three labeled actions to wrap inside the pane while preserving the desktop left/right grouping when space is available.
|
Tablet embedded Planning keeps the desktop two-pane shell, so the summary footer must absorb the narrower detail pane instead of relying on the mobile full-width stack. Allow the three labeled actions to wrap inside the pane while preserving the desktop left/right grouping when space is available.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
DEFAULT_TASK_PRIORITY,
|
DEFAULT_TASK_PRIORITY,
|
||||||
TASK_PRIORITIES,
|
TASK_PRIORITIES,
|
||||||
THINKING_LEVELS,
|
THINKING_LEVELS,
|
||||||
|
formatPlanningPlanMd,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
@@ -2874,23 +2875,18 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{view.type === "plan_review" && (
|
{view.type === "plan_review" && (
|
||||||
<div className="planning-summary" data-testid="planning-plan-review">
|
<div className="planning-summary planning-plan-review" data-testid="planning-plan-review">
|
||||||
<div className="planning-view-scroll planning-summary-scroll">
|
<div
|
||||||
<h4>{view.summary.title}</h4>
|
className="planning-view-scroll planning-summary-scroll planning-plan-scroll"
|
||||||
<p>{view.summary.description}</p>
|
data-testid="planning-plan-scroll"
|
||||||
{(view.summary.proposedChanges?.length ?? 0) > 0 && (
|
>
|
||||||
<section>
|
<article className="planning-plan-document">
|
||||||
<h5>{t("planning.proposedChanges", "What to change")}</h5>
|
<MailboxMessageContent
|
||||||
<ul>{view.summary.proposedChanges!.map((item) => <li key={item}>{item}</li>)}</ul>
|
className="planning-plan-markdown markdown-body"
|
||||||
</section>
|
content={formatPlanningPlanMd(view.summary)}
|
||||||
)}
|
testId="planning-plan-markdown"
|
||||||
{(view.summary.acceptanceCriteria?.length ?? 0) > 0 && (
|
/>
|
||||||
<section>
|
</article>
|
||||||
<h5>{t("planning.acceptanceCriteria", "Acceptance criteria")}</h5>
|
|
||||||
<ul>{view.summary.acceptanceCriteria!.map((item) => <li key={item}>{item}</li>)}</ul>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
{view.summary.keyDeliverables.length > 0 && <ul>{view.summary.keyDeliverables.map((item) => <li key={item}>{item}</li>)}</ul>}
|
|
||||||
<fieldset className="planning-refine-focus" data-testid="planning-refine-focus">
|
<fieldset className="planning-refine-focus" data-testid="planning-refine-focus">
|
||||||
<legend>{t("planning.refineFocus", "Focus the next question")}</legend>
|
<legend>{t("planning.refineFocus", "Focus the next question")}</legend>
|
||||||
<div className="planning-radio-group" role="radiogroup">
|
<div className="planning-radio-group" role="radiogroup">
|
||||||
@@ -2936,17 +2932,20 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div className="planning-summary-actions">
|
</div>
|
||||||
<button
|
<div
|
||||||
type="button"
|
className="planning-actions planning-summary-actions planning-plan-actions"
|
||||||
className="btn"
|
data-testid="planning-plan-actions"
|
||||||
disabled={refineFocus.trim().length === 0}
|
>
|
||||||
onClick={() => void handleRefineFromPlan()}
|
<button
|
||||||
>
|
type="button"
|
||||||
{t("planning.refine", "Refine")}
|
className="btn"
|
||||||
</button>
|
disabled={refineFocus.trim().length === 0}
|
||||||
<button type="button" className="btn btn-primary" onClick={() => void handleValidatePlan()}>{t("planning.validatePlan", "Validate")}</button>
|
onClick={() => void handleRefineFromPlan()}
|
||||||
</div>
|
>
|
||||||
|
{t("planning.refine", "Refine")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => void handleValidatePlan()}>{t("planning.validatePlan", "Validate")}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const base = { id: "session-1", title: "Secure plan", projectId: "project-1", up
|
|||||||
function renderSession(session: Record<string, unknown>) { return render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-1" resumeSessionId="session-1" />); }
|
function renderSession(session: Record<string, unknown>) { return render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-1" resumeSessionId="session-1" />); }
|
||||||
const summaryWithRefinements = {
|
const summaryWithRefinements = {
|
||||||
...mockSummary,
|
...mockSummary,
|
||||||
|
description: "Build a **reviewed** recovery workflow with an operator [runbook](https://example.com/runbook).",
|
||||||
proposedChanges: ["Change the authentication API", "Add durable session recovery"],
|
proposedChanges: ["Change the authentication API", "Add durable session recovery"],
|
||||||
acceptanceCriteria: ["Refresh preserves generation", "The plan is reviewable before questions"],
|
acceptanceCriteria: ["Refresh preserves generation", "The plan is reviewable before questions"],
|
||||||
suggestedRefinements: ["Security boundaries", "Rollout strategy", "Failure recovery", "Accessibility", "Observability"],
|
suggestedRefinements: ["Security boundaries", "Rollout strategy", "Failure recovery", "Accessibility", "Observability"],
|
||||||
@@ -43,7 +44,11 @@ describe("PlanningModeModal sequential flow", () => {
|
|||||||
it("renders plan review after an answered turn without retired interview panes", async () => {
|
it("renders plan review after an answered turn without retired interview panes", async () => {
|
||||||
mockFetchAiSession.mockResolvedValue({ ...base, status: "awaiting_input", currentQuestion: null, result: JSON.stringify(summaryWithRefinements), inputPayload: JSON.stringify({ initialPlan: "Secure accounts" }) });
|
mockFetchAiSession.mockResolvedValue({ ...base, status: "awaiting_input", currentQuestion: null, result: JSON.stringify(summaryWithRefinements), inputPayload: JSON.stringify({ initialPlan: "Secure accounts" }) });
|
||||||
renderSession({});
|
renderSession({});
|
||||||
expect(await screen.findByTestId("planning-plan-review")).toHaveTextContent("Build authentication system");
|
const planReview = await screen.findByTestId("planning-plan-review");
|
||||||
|
expect(planReview).toHaveTextContent("Build authentication system");
|
||||||
|
expect(screen.getByTestId("planning-plan-markdown").querySelector("h1")).toHaveTextContent("Build authentication system");
|
||||||
|
expect(screen.getByTestId("planning-plan-markdown").querySelector("strong")).toHaveTextContent("reviewed");
|
||||||
|
expect(screen.getByRole("link", { name: "runbook" })).toHaveAttribute("href", "https://example.com/runbook");
|
||||||
expect(screen.getByText("What to change")).toBeInTheDocument();
|
expect(screen.getByText("What to change")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Change the authentication API")).toBeInTheDocument();
|
expect(screen.getByText("Change the authentication API")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Acceptance criteria")).toBeInTheDocument();
|
expect(screen.getByText("Acceptance criteria")).toBeInTheDocument();
|
||||||
@@ -57,6 +62,10 @@ describe("PlanningModeModal sequential flow", () => {
|
|||||||
expect(screen.getByRole("button", { name: "Refine" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "Refine" })).toBeDisabled();
|
||||||
expect(screen.getByRole("button", { name: "Validate" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Validate" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Sessions" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Sessions" })).toBeInTheDocument();
|
||||||
|
const scrollRegion = screen.getByTestId("planning-plan-scroll");
|
||||||
|
const actionBar = screen.getByTestId("planning-plan-actions");
|
||||||
|
expect(scrollRegion).not.toContainElement(actionBar);
|
||||||
|
expect(planReview).toContainElement(actionBar);
|
||||||
expect(document.querySelector(".planning-running-plan")).toBeNull();
|
expect(document.querySelector(".planning-running-plan")).toBeNull();
|
||||||
expect(document.querySelector(".planning-answered-history")).toBeNull();
|
expect(document.querySelector(".planning-answered-history")).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -104,6 +113,10 @@ describe("PlanningModeModal sequential flow", () => {
|
|||||||
expect(screen.getByRole("radio", { name: "Security boundaries" })).toBeInTheDocument();
|
expect(screen.getByRole("radio", { name: "Security boundaries" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("radio", { name: "Observability" })).toBeInTheDocument();
|
expect(screen.getByRole("radio", { name: "Observability" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("radio", { name: "Write your own focus" })).toBeInTheDocument();
|
expect(screen.getByRole("radio", { name: "Write your own focus" })).toBeInTheDocument();
|
||||||
|
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" }));
|
||||||
});
|
});
|
||||||
it("restores a validated unlinked session to create-only retry", async () => {
|
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 }) });
|
mockFetchAiSession.mockResolvedValue({ ...base, status: "complete", currentQuestion: null, result: JSON.stringify(mockSummary), inputPayload: JSON.stringify({ validated: true }) });
|
||||||
|
|||||||
@@ -8,4 +8,12 @@ describe("PlanningModeModal sequential layout", () => {
|
|||||||
expect(css).not.toMatch(/planning-compact-pane-switcher|planning-running-plan|planning-answered-history/);
|
expect(css).not.toMatch(/planning-compact-pane-switcher|planning-running-plan|planning-answered-history/);
|
||||||
expect(css).toContain("planning-summary-actions");
|
expect(css).toContain("planning-summary-actions");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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(/@media \(max-width: 1024px\)[\s\S]*?\.planning-plan-actions \.btn\s*\{[^}]*width\s*:\s*100%\s*;/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Planning Mode browser E2E</title></head>
|
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Planning Mode browser E2E</title><style>html, body, #root { height: 100%; } #root { display: flex; min-height: 0; }</style></head>
|
||||||
<body><div id="root"></div><script type="module" src="/app/planning-browser-e2e-fixture.tsx"></script></body>
|
<body><div id="root"></div><script type="module" src="/app/planning-browser-e2e-fixture.tsx"></script></body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "./styles.css";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import { I18nextProvider, initReactI18next } from "react-i18next";
|
import { I18nextProvider, initReactI18next } from "react-i18next";
|
||||||
import { PlanningModeModal } from "./components/PlanningModeModal";
|
import { PlanningModeModal } from "./components/PlanningModeModal";
|
||||||
@@ -8,13 +9,18 @@ import { NavigationHistoryProvider } from "./hooks/useNavigationHistory";
|
|||||||
|
|
||||||
const summary = {
|
const summary = {
|
||||||
title: "Adaptive planning workflow",
|
title: "Adaptive planning workflow",
|
||||||
description: "An evolving plan assembled from the interview answers.",
|
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"],
|
||||||
|
acceptanceCriteria: ["Markdown structure is visible", "Mobile actions remain at the bottom of the planning pane"],
|
||||||
suggestedSize: "M",
|
suggestedSize: "M",
|
||||||
priority: "normal",
|
priority: "normal",
|
||||||
suggestedDependencies: [],
|
suggestedDependencies: [],
|
||||||
keyDeliverables: ["Adaptive questions", "Validated task"],
|
keyDeliverables: ["Adaptive questions", "Validated task", "Responsive plan review"],
|
||||||
|
suggestedRefinements: ["Security boundaries", "Rollout strategy", "Failure recovery", "Accessibility", "Observability", "Data migration", "Performance", "Operational readiness", "API compatibility", "Privacy", "Analytics", "Localization", "Offline behavior", "Permissions", "Documentation", "Support readiness"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showPlanReview = new URLSearchParams(window.location.search).get("surface") === "plan-review";
|
||||||
|
|
||||||
const questions = [
|
const questions = [
|
||||||
{
|
{
|
||||||
id: "q-goal",
|
id: "q-goal",
|
||||||
@@ -102,10 +108,14 @@ window.fetch = async (input, init = {}) => {
|
|||||||
return json({ currentQuestion: questions[0], summary, history: [] });
|
return json({ currentQuestion: questions[0], summary, history: [] });
|
||||||
}
|
}
|
||||||
if (url.includes("/planning/planning-browser-e2e/validate") && method === "POST") return json({ summary, validated: true });
|
if (url.includes("/planning/planning-browser-e2e/validate") && method === "POST") return json({ summary, validated: true });
|
||||||
if (url.includes("/planning/create-task") && method === "POST") return json({ id: "FN-BROWSER", description: summary.description, column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
|
if (url.includes("/planning/create-task") && method === "POST") return json({ task: { id: "FN-BROWSER", description: summary.description, column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, alreadyCreated: false });
|
||||||
|
if (showPlanReview && url.includes("/ai-sessions/planning-browser-e2e")) {
|
||||||
|
return json({ id: "planning-browser-e2e", title: summary.title, projectId: "project-browser", status: "awaiting_input", currentQuestion: null, result: JSON.stringify(summary), inputPayload: "{}", conversationHistory: "[]", thinkingOutput: "", updatedAt: new Date().toISOString(), archived: false });
|
||||||
|
}
|
||||||
if (url.includes("/ai-sessions")) return json({ sessions: [] });
|
if (url.includes("/ai-sessions")) return json({ sessions: [] });
|
||||||
if (url.includes("/models")) return json({ models: [], favoriteProviders: [], favoriteModels: [] });
|
if (url.includes("/models")) return json({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||||||
if (url.includes("/settings")) return json({});
|
if (url.includes("/settings")) return json({});
|
||||||
|
if (url.includes("/events") || url.includes("/diagnostics/resume-events")) return json({ success: true });
|
||||||
if (url.includes("/planning/create-draft")) return json({ sessionId: "planning-browser-e2e", title: "Adaptive planning workflow" });
|
if (url.includes("/planning/create-draft")) return json({ sessionId: "planning-browser-e2e", title: "Adaptive planning workflow" });
|
||||||
if (url.includes("/planning/")) return json({ success: true });
|
if (url.includes("/planning/")) return json({ success: true });
|
||||||
return originalFetch(input, init);
|
return originalFetch(input, init);
|
||||||
@@ -124,6 +134,7 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
onTasksCreated={() => undefined}
|
onTasksCreated={() => undefined}
|
||||||
tasks={[]}
|
tasks={[]}
|
||||||
presentation="embedded"
|
presentation="embedded"
|
||||||
|
resumeSessionId={showPlanReview ? "planning-browser-e2e" : undefined}
|
||||||
/>
|
/>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</NavigationHistoryProvider>
|
</NavigationHistoryProvider>
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ describe.runIf(executablePath)("Planning Mode browser E2E", () => {
|
|||||||
await server.pluginContainer.close();
|
await server.pluginContainer.close();
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
|
|
||||||
it("keeps an adaptive interview open until Validate and then creates a task", async () => {
|
it("keeps the adaptive question and edit-answer loop working", async () => {
|
||||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||||
page.on("console", (event) => console.log(`[planning-browser-e2e] ${event.text?.() ?? ""}`));
|
page.on("console", (event) => console.log(`[planning-browser-e2e] ${event.text?.() ?? ""}`));
|
||||||
page.on("pageerror", (event) => console.error(`[planning-browser-e2e] ${event.message ?? ""}`));
|
page.on("pageerror", (event) => console.error(`[planning-browser-e2e] ${event.message ?? ""}`));
|
||||||
@@ -87,8 +87,6 @@ describe.runIf(executablePath)("Planning Mode browser E2E", () => {
|
|||||||
await page.getByLabel("What do you want to build?").fill("Make Planning Mode adaptive");
|
await page.getByLabel("What do you want to build?").fill("Make Planning Mode adaptive");
|
||||||
await page.getByRole("button", { name: "Start Planning" }).click();
|
await page.getByRole("button", { name: "Start Planning" }).click();
|
||||||
await expectVisible(page.getByText("Which user outcome matters most?"));
|
await expectVisible(page.getByText("Which user outcome matters most?"));
|
||||||
await expectVisible(page.getByLabel("Running plan").getByRole("heading", { name: "Adaptive planning workflow" }));
|
|
||||||
await expectVisible(page.getByRole("button", { name: "Validate plan" }));
|
|
||||||
|
|
||||||
await page.getByLabel("Speed").check();
|
await page.getByLabel("Speed").check();
|
||||||
await page.getByRole("button", { name: "Next question" }).click();
|
await page.getByRole("button", { name: "Next question" }).click();
|
||||||
@@ -101,15 +99,58 @@ describe.runIf(executablePath)("Planning Mode browser E2E", () => {
|
|||||||
await page.getByLabel("Depth").check();
|
await page.getByLabel("Depth").check();
|
||||||
await page.getByRole("button", { name: "Next question" }).click();
|
await page.getByRole("button", { name: "Next question" }).click();
|
||||||
await expectVisible(page.getByText("Who should receive this first?"));
|
await expectVisible(page.getByText("Who should receive this first?"));
|
||||||
await expectVisible(page.getByLabel("Running plan").getByRole("heading", { name: "Adaptive planning workflow" }));
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Validate plan" }).click();
|
|
||||||
await expectVisible(page.getByRole("button", { name: "Create Single Task" }));
|
|
||||||
await page.getByRole("button", { name: "Create Single Task" }).click();
|
|
||||||
for (let attempt = 0; attempt < 20 && await page.evaluate(() => document.body.dataset.createdTask) !== "FN-BROWSER"; attempt += 1) {
|
|
||||||
await page.waitForTimeout(50);
|
|
||||||
}
|
|
||||||
expect(await page.locator("body").getAttribute("data-created-task")).toBe("FN-BROWSER");
|
|
||||||
await page.close();
|
await page.close();
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
|
it("keeps the Markdown plan scrollable above a bottom action bar on desktop and mobile", async () => {
|
||||||
|
for (const viewport of [{ width: 1440, height: 900 }, { width: 390, height: 568 }]) {
|
||||||
|
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" }));
|
||||||
|
|
||||||
|
const layout = await page.evaluate(() => {
|
||||||
|
const review = document.querySelector<HTMLElement>("[data-testid='planning-plan-review']")!;
|
||||||
|
const scroll = document.querySelector<HTMLElement>("[data-testid='planning-plan-scroll']")!;
|
||||||
|
const actions = document.querySelector<HTMLElement>("[data-testid='planning-plan-actions']")!;
|
||||||
|
const buttons = [...actions.querySelectorAll<HTMLElement>("button")];
|
||||||
|
const reviewRect = review.getBoundingClientRect();
|
||||||
|
const scrollRect = scroll.getBoundingClientRect();
|
||||||
|
const actionsRect = actions.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
actionsInsideReview: review.contains(actions),
|
||||||
|
actionsInsideScroll: scroll.contains(actions),
|
||||||
|
actionsAtBottom: Math.abs(reviewRect.bottom - actionsRect.bottom) <= 1,
|
||||||
|
scrollEndsAtActions: Math.abs(scrollRect.bottom - actionsRect.top) <= 1,
|
||||||
|
scrollable: scroll.scrollHeight > scroll.clientHeight,
|
||||||
|
scrollOwnerConfigured: getComputedStyle(scroll).overflowY === "auto",
|
||||||
|
buttonsShareRow: buttons.length === 2 && Math.abs(buttons[0]!.getBoundingClientRect().top - buttons[1]!.getBoundingClientRect().top) <= 1,
|
||||||
|
markdownRendered: Boolean(review.querySelector("h1") && review.querySelector("strong")),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(layout).toEqual({
|
||||||
|
actionsInsideReview: true,
|
||||||
|
actionsInsideScroll: false,
|
||||||
|
actionsAtBottom: true,
|
||||||
|
scrollEndsAtActions: true,
|
||||||
|
scrollable: true,
|
||||||
|
scrollOwnerConfigured: true,
|
||||||
|
buttonsShareRow: true,
|
||||||
|
markdownRendered: true,
|
||||||
|
});
|
||||||
|
if (viewport.width > 1024) {
|
||||||
|
await page.getByLabel("Security boundaries").check();
|
||||||
|
await page.getByRole("button", { name: "Refine" }).click();
|
||||||
|
await expectVisible(page.getByText("Who should receive this first?"));
|
||||||
|
} else {
|
||||||
|
await page.getByRole("button", { name: "Validate" }).click();
|
||||||
|
for (let attempt = 0; attempt < 20 && await page.evaluate(() => document.body.dataset.createdTask) !== "FN-BROWSER"; attempt += 1) {
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
}
|
||||||
|
expect(await page.locator("body").getAttribute("data-created-task")).toBe("FN-BROWSER");
|
||||||
|
}
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -132,6 +132,19 @@ describe("reactive Planning Mode question contract", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("asks for an operator-facing plan in Markdown at every plan-writing boundary", () => {
|
||||||
|
const prompts = [
|
||||||
|
PLANNING_SYSTEM_PROMPT,
|
||||||
|
formatInitialRunningPlanRequestForAgent("Build secure accounts"),
|
||||||
|
formatResponseForAgent(FIRST_QUESTION, { scope: "secure" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const prompt of prompts) {
|
||||||
|
expect(prompt).toMatch(/plan in Markdown/i);
|
||||||
|
}
|
||||||
|
expect(prompts.at(-1)).toMatch(/without asking another question/i);
|
||||||
|
});
|
||||||
|
|
||||||
it("repairs malformed select options and appends one localized Other option", () => {
|
it("repairs malformed select options and appends one localized Other option", () => {
|
||||||
const question = normalizePlanningQuestion({
|
const question = normalizePlanningQuestion({
|
||||||
id: "security",
|
id: "security",
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ export const PLANNING_SYSTEM_PROMPT = `## Planning Mode interaction adapter
|
|||||||
|
|
||||||
First analyze the codebase and active board with the available readonly tools, fn_task_list, and fn_task_show. Treat the workflow planning template above as the quality bar and PROMPT.md structure for the evolving plan, but do not write PROMPT.md or use write tools during this interview.
|
First analyze the codebase and active board with the available readonly tools, fn_task_list, and fn_task_show. Treat the workflow planning template above as the quality bar and PROMPT.md structure for the evolving plan, but do not write PROMPT.md or use write tools during this interview.
|
||||||
|
|
||||||
Start by producing a concrete initial plan for review without asking a question. Ask exactly one next, high-impact question only when the user explicitly requests a refine turn. After an answer, update the running plan and do not ask a question. A JSON response with type "complete" means only that the current plan update is ready for review; it never validates or terminates the session. Only the user can validate the plan.
|
Start by producing a concrete initial plan for review without asking a question. Author the operator-facing plan in Markdown: write the description as concise GitHub-flavored Markdown, while the structured change, acceptance, dependency, and deliverable fields become its Markdown sections and lists. Ask exactly one next, high-impact question only when the user explicitly requests a refine turn. After an answer, update the running plan and do not ask a question. A JSON response with type "complete" means only that the current plan update is ready for review; it never validates or terminates the session. Only the user can validate the plan.
|
||||||
|
|
||||||
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"]}}.
|
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"]}}.
|
||||||
|
|
||||||
@@ -2160,6 +2160,7 @@ export function formatInitialPlanRequestForAgent(initialPlan: string): string {
|
|||||||
export function formatInitialRunningPlanRequestForAgent(initialPlan: string): string {
|
export function formatInitialRunningPlanRequestForAgent(initialPlan: string): string {
|
||||||
return [
|
return [
|
||||||
"Create a concrete initial implementation plan from this operator idea.",
|
"Create a concrete initial implementation plan from this operator idea.",
|
||||||
|
"Author the operator-facing plan in Markdown. Write the description as concise GitHub-flavored Markdown; the structured proposed changes, acceptance criteria, dependencies, and deliverables will render as Markdown sections and lists.",
|
||||||
"Inspect the relevant codebase and active-board context before drafting it. Make the description specific about the affected behavior and intended outcome. Provide concrete proposedChanges that name what behavior, component, interface, data, or configuration should change, and acceptanceCriteria stated as observable pass/fail outcomes. Make every key deliverable an actionable work item rather than generic planning advice.",
|
"Inspect the relevant codebase and active-board context before drafting it. Make the description specific about the affected behavior and intended outcome. Provide concrete proposedChanges that name what behavior, component, interface, data, or configuration should change, and acceptanceCriteria stated as observable pass/fail outcomes. Make every key deliverable an actionable work item rather than generic planning advice.",
|
||||||
"Also propose concise suggestedRefinements covering every distinct, high-value unresolved area the operator could explore next; do not cap the list at three.",
|
"Also propose concise suggestedRefinements covering every distinct, high-value unresolved area the operator could explore next; do not cap the list at three.",
|
||||||
"Return only type:\"complete\" JSON with title, description, proposedChanges, acceptanceCriteria, suggestedSize, priority, suggestedDependencies, keyDeliverables, and suggestedRefinements. Do not ask a question yet and do not validate the plan; the operator will review it and explicitly choose Refine or Validate.",
|
"Return only type:\"complete\" JSON with title, description, proposedChanges, acceptanceCriteria, suggestedSize, priority, suggestedDependencies, keyDeliverables, and suggestedRefinements. Do not ask a question yet and do not validate the plan; the operator will review it and explicitly choose Refine or Validate.",
|
||||||
@@ -3146,7 +3147,7 @@ export function formatResponseForAgent(
|
|||||||
System prompts can be displaced by long tool/context turns. Repeat the per-answer contract at the invocation boundary
|
System prompts can be displaced by long tool/context turns. Repeat the per-answer contract at the invocation boundary
|
||||||
so every submitted answer steers the following high-impact question instead of inviting a model-generated completion.
|
so every submitted answer steers the following high-impact question instead of inviting a model-generated completion.
|
||||||
*/
|
*/
|
||||||
return `${answerContext}\n\nUpdate only the runningPlan fields (title, description, concrete proposedChanges, observable acceptanceCriteria, suggestedSize, optional priority, suggestedDependencies, concrete keyDeliverables, and all distinct high-value suggestedRefinements) informed by this answer; do not cap suggestedRefinements at three. Never list interview questions as deliverables or PROMPT.md sections such as Mission, Steps, File Scope, Review Level, Completion Criteria, or Do NOT. Then ask exactly one new, high-impact question that does not repeat a prior question. Offer alternatives with pros and cons. Do not validate the plan; only the user can validate it.`;
|
return `${answerContext}\n\nUpdate only the runningPlan fields (title, description, concrete proposedChanges, observable acceptanceCriteria, suggestedSize, optional priority, suggestedDependencies, concrete keyDeliverables, and all distinct high-value suggestedRefinements) informed by this answer; do not cap suggestedRefinements at three. Author the operator-facing plan in Markdown: use concise GitHub-flavored Markdown in the description, with the structured fields supplying its Markdown sections and lists. Never list interview questions as deliverables or PROMPT.md sections such as Mission, Steps, File Scope, Review Level, Completion Criteria, or Do NOT. Return the updated plan for review without asking another question. Do not validate the plan; only the user can validate it.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function coerceResponseRecord(question: PlanningQuestion, response: unknown): Record<string, unknown> {
|
function coerceResponseRecord(question: PlanningQuestion, response: unknown): Record<string, unknown> {
|
||||||
|
|||||||
Reference in New Issue
Block a user