From 0a01cb164d7c860ff7f4043ab34726ace8e8f5d1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 20 Jul 2026 17:35:06 -0700 Subject: [PATCH] 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. --- .changeset/polish-planning-markdown-review.md | 7 ++ .../src/__tests__/planning-plan-md.test.ts | 18 +++ packages/core/src/planning-plan-md.ts | 17 +-- packages/core/src/types.ts | 19 ++++ .../app/components/PlanningModeModal.css | 103 +++++++++++++++++- .../app/components/PlanningModeModal.tsx | 55 +++++----- .../PlanningModeModal.planning-flow.test.tsx | 15 ++- ...PlanningModeModal.ui-interactions.test.tsx | 8 ++ .../app/planning-browser-e2e-fixture.html | 2 +- .../app/planning-browser-e2e-fixture.tsx | 17 ++- .../__tests__/planning-browser-e2e.test.ts | 65 +++++++++-- .../planning-infinite-interview.test.ts | 13 +++ packages/dashboard/src/planning.ts | 5 +- 13 files changed, 279 insertions(+), 65 deletions(-) create mode 100644 .changeset/polish-planning-markdown-review.md diff --git a/.changeset/polish-planning-markdown-review.md b/.changeset/polish-planning-markdown-review.md new file mode 100644 index 0000000000..51b4817bc8 --- /dev/null +++ b/.changeset/polish-planning-markdown-review.md @@ -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. diff --git a/packages/core/src/__tests__/planning-plan-md.test.ts b/packages/core/src/__tests__/planning-plan-md.test.ts index 90aa20cafe..d225af0c1b 100644 --- a/packages/core/src/__tests__/planning-plan-md.test.ts +++ b/packages/core/src/__tests__/planning-plan-md.test.ts @@ -32,4 +32,22 @@ describe("Planning Mode plan.md", () => { 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"], + })); + }); }); diff --git a/packages/core/src/planning-plan-md.ts b/packages/core/src/planning-plan-md.ts index fd867c9d04..8eebf51866 100644 --- a/packages/core/src/planning-plan-md.ts +++ b/packages/core/src/planning-plan-md.ts @@ -1,26 +1,11 @@ import type { PlanningSummary } from "./types.js"; +export { formatPlanningPlanMd } from "./types.js"; /* FNXC:PlanningMode 2026-07-20-12:00: 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. */ -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: A plan description may itself mention plan.md headings. Parse only the final, exact diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b2ccbc1cf1..4ff04851b6 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -5675,6 +5675,25 @@ export interface PlanningSummary { 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 */ export type PlanningResponse = | { type: "question"; data: PlanningQuestion } diff --git a/packages/dashboard/app/components/PlanningModeModal.css b/packages/dashboard/app/components/PlanningModeModal.css index 88c435b54c..72efb97468 100644 --- a/packages/dashboard/app/components/PlanningModeModal.css +++ b/packages/dashboard/app/components/PlanningModeModal.css @@ -1186,7 +1186,7 @@ margins so paragraphs and lists do not create unintended gaps. .planning-summary { display: flex; flex-direction: column; - gap: 20px; + gap: var(--space-xl); flex: 1; min-height: 0; overflow: hidden; @@ -1205,7 +1205,72 @@ margins so paragraphs and lists do not create unintended gaps. } .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 { @@ -1402,6 +1467,17 @@ margins so paragraphs and lists do not create unintended gaps. 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 { display: flex; gap: var(--space-sm); @@ -1410,6 +1486,29 @@ margins so paragraphs and lists do not create unintended gaps. 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: 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. diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index c6c70c3ce3..16283d92df 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -9,6 +9,7 @@ import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, THINKING_LEVELS, + formatPlanningPlanMd, getErrorMessage, } from "@fusion/core"; import { @@ -2874,23 +2875,18 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat )} {view.type === "plan_review" && ( -
-
-

{view.summary.title}

-

{view.summary.description}

- {(view.summary.proposedChanges?.length ?? 0) > 0 && ( -
-
{t("planning.proposedChanges", "What to change")}
-
    {view.summary.proposedChanges!.map((item) =>
  • {item}
  • )}
-
- )} - {(view.summary.acceptanceCriteria?.length ?? 0) > 0 && ( -
-
{t("planning.acceptanceCriteria", "Acceptance criteria")}
-
    {view.summary.acceptanceCriteria!.map((item) =>
  • {item}
  • )}
-
- )} - {view.summary.keyDeliverables.length > 0 &&
    {view.summary.keyDeliverables.map((item) =>
  • {item}
  • )}
} +
+
+
+ +
{t("planning.refineFocus", "Focus the next question")}
@@ -2936,17 +2932,20 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat )}
-
- - -
+
+
+ +
)} diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx index bc3d5d9cfd..858d69663e 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -24,6 +24,7 @@ const base = { id: "session-1", title: "Secure plan", projectId: "project-1", up function renderSession(session: Record) { return render(); } const summaryWithRefinements = { ...mockSummary, + description: "Build a **reviewed** recovery workflow with an operator [runbook](https://example.com/runbook).", proposedChanges: ["Change the authentication API", "Add durable session recovery"], acceptanceCriteria: ["Refresh preserves generation", "The plan is reviewable before questions"], 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 () => { mockFetchAiSession.mockResolvedValue({ ...base, status: "awaiting_input", currentQuestion: null, result: JSON.stringify(summaryWithRefinements), inputPayload: JSON.stringify({ initialPlan: "Secure accounts" }) }); 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("Change the authentication API")).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: "Validate" })).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-answered-history")).toBeNull(); }); @@ -104,6 +113,10 @@ describe("PlanningModeModal sequential flow", () => { 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(); + 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 () => { mockFetchAiSession.mockResolvedValue({ ...base, status: "complete", currentQuestion: null, result: JSON.stringify(mockSummary), inputPayload: JSON.stringify({ validated: true }) }); diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.ui-interactions.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.ui-interactions.test.tsx index 4fb7f5f4d1..c0f7756f69 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.ui-interactions.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.ui-interactions.test.tsx @@ -8,4 +8,12 @@ describe("PlanningModeModal sequential layout", () => { expect(css).not.toMatch(/planning-compact-pane-switcher|planning-running-plan|planning-answered-history/); 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*;/); + }); }); diff --git a/packages/dashboard/app/planning-browser-e2e-fixture.html b/packages/dashboard/app/planning-browser-e2e-fixture.html index d2a3951309..c7ac544ec4 100644 --- a/packages/dashboard/app/planning-browser-e2e-fixture.html +++ b/packages/dashboard/app/planning-browser-e2e-fixture.html @@ -1,5 +1,5 @@ - Planning Mode browser E2E + Planning Mode browser E2E
diff --git a/packages/dashboard/app/planning-browser-e2e-fixture.tsx b/packages/dashboard/app/planning-browser-e2e-fixture.tsx index 46f27d5453..e0a804849b 100644 --- a/packages/dashboard/app/planning-browser-e2e-fixture.tsx +++ b/packages/dashboard/app/planning-browser-e2e-fixture.tsx @@ -1,5 +1,6 @@ import React from "react"; import { createRoot } from "react-dom/client"; +import "./styles.css"; import i18n from "i18next"; import { I18nextProvider, initReactI18next } from "react-i18next"; import { PlanningModeModal } from "./components/PlanningModeModal"; @@ -8,13 +9,18 @@ import { NavigationHistoryProvider } from "./hooks/useNavigationHistory"; const summary = { 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", priority: "normal", 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 = [ { id: "q-goal", @@ -102,10 +108,14 @@ window.fetch = async (input, init = {}) => { 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/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("/models")) return json({ models: [], favoriteProviders: [], favoriteModels: [] }); 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/")) return json({ success: true }); return originalFetch(input, init); @@ -124,6 +134,7 @@ createRoot(document.getElementById("root")!).render( onTasksCreated={() => undefined} tasks={[]} presentation="embedded" + resumeSessionId={showPlanReview ? "planning-browser-e2e" : undefined} /> diff --git a/packages/dashboard/src/__tests__/planning-browser-e2e.test.ts b/packages/dashboard/src/__tests__/planning-browser-e2e.test.ts index ce7c2be2e8..b4cab97830 100644 --- a/packages/dashboard/src/__tests__/planning-browser-e2e.test.ts +++ b/packages/dashboard/src/__tests__/planning-browser-e2e.test.ts @@ -78,7 +78,7 @@ describe.runIf(executablePath)("Planning Mode browser E2E", () => { await server.pluginContainer.close(); }, 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 } }); page.on("console", (event) => console.log(`[planning-browser-e2e] ${event.text?.() ?? ""}`)); 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.getByRole("button", { name: "Start Planning" }).click(); 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.getByRole("button", { name: "Next question" }).click(); @@ -101,15 +99,58 @@ describe.runIf(executablePath)("Planning Mode browser E2E", () => { await page.getByLabel("Depth").check(); await page.getByRole("button", { name: "Next question" }).click(); 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(); }, 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("[data-testid='planning-plan-review']")!; + const scroll = document.querySelector("[data-testid='planning-plan-scroll']")!; + const actions = document.querySelector("[data-testid='planning-plan-actions']")!; + const buttons = [...actions.querySelectorAll("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); }); diff --git a/packages/dashboard/src/__tests__/planning-infinite-interview.test.ts b/packages/dashboard/src/__tests__/planning-infinite-interview.test.ts index 8ceb684b56..ff4b8e0bd7 100644 --- a/packages/dashboard/src/__tests__/planning-infinite-interview.test.ts +++ b/packages/dashboard/src/__tests__/planning-infinite-interview.test.ts @@ -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", () => { const question = normalizePlanningQuestion({ id: "security", diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index 04aed73e92..c072a6da2f 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -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. -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"]}}. @@ -2160,6 +2160,7 @@ export function formatInitialPlanRequestForAgent(initialPlan: string): string { export function formatInitialRunningPlanRequestForAgent(initialPlan: string): string { return [ "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.", "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.", @@ -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 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 {