FN-7317: fix mobile planning summary controls

Fix mobile Planning Mode summary controls so description actions stay tappable and accessible.

- Move Markdown and Expand/Collapse controls outside the Description label to avoid mobile tap retargeting.\n- Add accessible labels and shared header/action styling for summary description controls.\n- Cover mobile expand/collapse behavior alongside the markdown preview toggle.\n- Add a patch changeset for the published CLI package.\n\nFiles changed:\n .changeset/fn-7317-mobile-planning-summary.md      |  7 +++\n .../dashboard/app/components/PlanningModeModal.css | 26 ++++++++-\n .../dashboard/app/components/PlanningModeModal.tsx | 58 +++++++++++--------\n .../PlanningModeModal.ui-interactions.test.tsx     | 65 ++++++++++++++++++++--\n 4 files changed, 128 insertions(+), 28 deletions(-)

Fusion-Task-Id: FN-7317

Fusion-Task-Lineage: f13bc27a-ea4e-46cd-aebb-2fe0a3251496

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-30 14:53:14 -07:00
parent 21fc28698f
commit 9f7215851b
4 changed files with 128 additions and 28 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix mobile Planning Mode summary description Expand and Collapse controls.
category: fix
dev: Splits the summary description label from adjacent Markdown and Expand controls and adds mobile regression coverage.

View File

@@ -1127,13 +1127,25 @@ An empty footer must NOT reserve vertical space or paint its divider band. When
gap: var(--space-sm);
}
.planning-expand-btn {
.planning-summary-description-header {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.planning-summary-description-actions {
display: flex;
align-items: center;
gap: var(--space-md);
margin-left: auto;
}
.planning-expand-btn {
padding: 0;
background: none;
border: none;
color: var(--todo);
font-size: 12px;
font-size: calc(var(--space-sm) + var(--space-xs));
font-weight: 500;
cursor: pointer;
}
@@ -1561,6 +1573,16 @@ Tablet embedded Planning keeps the desktop two-pane shell, so the summary footer
grid-template-columns: minmax(0, 1fr);
}
.planning-summary-description-header {
align-items: flex-start;
flex-wrap: wrap;
}
.planning-summary-description-actions {
margin-left: 0;
margin-inline-start: auto;
}
.planning-example-chips {
flex-direction: column;
align-items: stretch;

View File

@@ -2819,33 +2819,47 @@ function SummaryView({
<div className="planning-summary-form">
<div className="form-group">
<label>
{t("planning.description", "Description")}
<button
type="button"
className="planning-expand-btn"
aria-pressed={renderMarkdown}
aria-label={renderMarkdown ? t("planning.showRawText", "Show raw text") : t("planning.showFormattedMarkdown", "Show formatted markdown")}
title={renderMarkdown ? t("planning.showRawText", "Show raw text") : t("planning.showFormattedMarkdown", "Show formatted markdown")}
data-testid="planning-description-markdown-toggle"
onClick={() => setRenderMarkdown(!renderMarkdown)}
>
{renderMarkdown ? t("planning.plain", "Plain") : t("planning.markdown", "Markdown")}
</button>
<button
type="button"
className="planning-expand-btn"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? t("planning.collapse", "Collapse") : t("planning.expand", "Expand")}
</button>
</label>
{/*
FNXC:PlanningSummaryMobile 2026-06-30-12:00:
Keep Description controls adjacent but outside the label. Mobile browsers can retarget taps from interactive descendants inside labels, which made the Expand/Collapse control appear inert while the textarea/preview state already supported expansion.
*/}
<div className="planning-summary-description-header">
<label id="planning-summary-description-label" htmlFor="planning-summary-description">
{t("planning.description", "Description")}
</label>
<div className="planning-summary-description-actions">
<button
type="button"
className="planning-expand-btn"
aria-pressed={renderMarkdown}
aria-label={renderMarkdown ? t("planning.showRawText", "Show raw text") : t("planning.showFormattedMarkdown", "Show formatted markdown")}
title={renderMarkdown ? t("planning.showRawText", "Show raw text") : t("planning.showFormattedMarkdown", "Show formatted markdown")}
data-testid="planning-description-markdown-toggle"
onClick={() => setRenderMarkdown(!renderMarkdown)}
>
{renderMarkdown ? t("planning.plain", "Plain") : t("planning.markdown", "Markdown")}
</button>
<button
type="button"
className="planning-expand-btn"
aria-pressed={isExpanded}
aria-label={isExpanded ? t("planning.collapseDescription", "Collapse description") : t("planning.expandDescription", "Expand description")}
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? t("planning.collapse", "Collapse") : t("planning.expand", "Expand")}
</button>
</div>
</div>
{renderMarkdown ? (
<div className={`planning-description-preview markdown-body ${isExpanded ? "expanded" : ""}`}>
<div
className={`planning-description-preview markdown-body ${isExpanded ? "expanded" : ""}`}
aria-labelledby="planning-summary-description-label"
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{summary.description}</ReactMarkdown>
</div>
) : (
<textarea
id="planning-summary-description"
ref={descriptionAutosizeRef}
className={`planning-textarea ${isExpanded ? "expanded" : ""}`}
value={summary.description}

View File

@@ -104,6 +104,7 @@ vi.mock("../../api", () => ({
rejectPlan: (...args: any[]) => mockRejectPlan(...args),
refineTask: (...args: any[]) => mockRefineTask(...args),
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
fetchTaskEffectiveSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
fetchModels: (...args: any[]) => mockFetchModels(...args),
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
@@ -213,7 +214,8 @@ describe("PlanningModeModal", () => {
/>
);
expect(screen.getByText("Definition")).toBeDefined();
expect(screen.getByText("Example task")).toBeDefined();
expect(screen.getByRole("button", { name: "Plan" })).toBeDefined();
expect(container.querySelector(".detail-body")).not.toBeNull();
});
});
@@ -937,12 +939,12 @@ describe("PlanningModeModal", () => {
});
describe("Summary markdown preview toggle", () => {
it("toggles description between plain textarea and formatted markdown preview", async () => {
async function renderPlanningSummary(description: string) {
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
setTimeout(() => {
handlers.onSummary?.({
...mockSummary,
description: "## Heading\n\n- item\n\n**bold**",
description,
});
}, 10);
@@ -952,7 +954,7 @@ describe("PlanningModeModal", () => {
};
});
const { container } = render(
const result = render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
@@ -971,6 +973,12 @@ describe("PlanningModeModal", () => {
expect(screen.getByText("Planning Complete!")).toBeDefined();
});
return result;
}
it("toggles description between plain textarea and formatted markdown preview", async () => {
const { container } = await renderPlanningSummary("## Heading\n\n- item\n\n**bold**");
expect(container.querySelector(".planning-textarea")).not.toBeNull();
expect(container.querySelector(".planning-description-preview")).toBeNull();
@@ -988,6 +996,55 @@ describe("PlanningModeModal", () => {
expect(container.querySelector(".planning-textarea")).not.toBeNull();
});
});
it("expands and collapses the mobile summary description without breaking the adjacent markdown toggle", async () => {
mockViewport("mobile");
const { container } = await renderPlanningSummary(
"## Heading\n\n" +
"This is a long planning description that should visibly change between collapsed and expanded summary states on mobile.\n\n" +
"- Keep markdown available\n" +
"- Keep expand available",
);
const markdownToggle = screen.getByTestId("planning-description-markdown-toggle");
const expandButton = screen.getByRole("button", { name: "Expand description" });
expect(markdownToggle.closest("label")).toBeNull();
expect(expandButton.closest("label")).toBeNull();
const textarea = container.querySelector<HTMLTextAreaElement>(".planning-textarea");
expect(textarea).not.toBeNull();
expect(textarea?.classList.contains("expanded")).toBe(false);
fireEvent.click(expandButton);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Collapse description" })).toBeDefined();
expect(container.querySelector(".planning-textarea")?.classList.contains("expanded")).toBe(true);
});
fireEvent.click(markdownToggle);
await waitFor(() => {
const preview = container.querySelector(".planning-description-preview");
expect(preview).not.toBeNull();
expect(preview?.classList.contains("expanded")).toBe(true);
expect(screen.getByRole("heading", { level: 2, name: "Heading" })).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: "Collapse description" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Expand description" })).toBeDefined();
expect(container.querySelector(".planning-description-preview")?.classList.contains("expanded")).toBe(false);
});
fireEvent.click(markdownToggle);
await waitFor(() => {
expect(container.querySelector(".planning-textarea")).not.toBeNull();
expect(container.querySelector(".planning-description-preview")).toBeNull();
});
});
});
});