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:
7
.changeset/fn-7317-mobile-planning-summary.md
Normal file
7
.changeset/fn-7317-mobile-planning-summary.md
Normal 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.
|
||||||
@@ -1127,13 +1127,25 @@ An empty footer must NOT reserve vertical space or paint its divider band. When
|
|||||||
gap: var(--space-sm);
|
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;
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-expand-btn {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--todo);
|
color: var(--todo);
|
||||||
font-size: 12px;
|
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
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);
|
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 {
|
.planning-example-chips {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|||||||
@@ -2819,33 +2819,47 @@ function SummaryView({
|
|||||||
|
|
||||||
<div className="planning-summary-form">
|
<div className="planning-summary-form">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>
|
{/*
|
||||||
{t("planning.description", "Description")}
|
FNXC:PlanningSummaryMobile 2026-06-30-12:00:
|
||||||
<button
|
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.
|
||||||
type="button"
|
*/}
|
||||||
className="planning-expand-btn"
|
<div className="planning-summary-description-header">
|
||||||
aria-pressed={renderMarkdown}
|
<label id="planning-summary-description-label" htmlFor="planning-summary-description">
|
||||||
aria-label={renderMarkdown ? t("planning.showRawText", "Show raw text") : t("planning.showFormattedMarkdown", "Show formatted markdown")}
|
{t("planning.description", "Description")}
|
||||||
title={renderMarkdown ? t("planning.showRawText", "Show raw text") : t("planning.showFormattedMarkdown", "Show formatted markdown")}
|
</label>
|
||||||
data-testid="planning-description-markdown-toggle"
|
<div className="planning-summary-description-actions">
|
||||||
onClick={() => setRenderMarkdown(!renderMarkdown)}
|
<button
|
||||||
>
|
type="button"
|
||||||
{renderMarkdown ? t("planning.plain", "Plain") : t("planning.markdown", "Markdown")}
|
className="planning-expand-btn"
|
||||||
</button>
|
aria-pressed={renderMarkdown}
|
||||||
<button
|
aria-label={renderMarkdown ? t("planning.showRawText", "Show raw text") : t("planning.showFormattedMarkdown", "Show formatted markdown")}
|
||||||
type="button"
|
title={renderMarkdown ? t("planning.showRawText", "Show raw text") : t("planning.showFormattedMarkdown", "Show formatted markdown")}
|
||||||
className="planning-expand-btn"
|
data-testid="planning-description-markdown-toggle"
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setRenderMarkdown(!renderMarkdown)}
|
||||||
>
|
>
|
||||||
{isExpanded ? t("planning.collapse", "Collapse") : t("planning.expand", "Expand")}
|
{renderMarkdown ? t("planning.plain", "Plain") : t("planning.markdown", "Markdown")}
|
||||||
</button>
|
</button>
|
||||||
</label>
|
<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 ? (
|
{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>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{summary.description}</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<textarea
|
<textarea
|
||||||
|
id="planning-summary-description"
|
||||||
ref={descriptionAutosizeRef}
|
ref={descriptionAutosizeRef}
|
||||||
className={`planning-textarea ${isExpanded ? "expanded" : ""}`}
|
className={`planning-textarea ${isExpanded ? "expanded" : ""}`}
|
||||||
value={summary.description}
|
value={summary.description}
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ vi.mock("../../api", () => ({
|
|||||||
rejectPlan: (...args: any[]) => mockRejectPlan(...args),
|
rejectPlan: (...args: any[]) => mockRejectPlan(...args),
|
||||||
refineTask: (...args: any[]) => mockRefineTask(...args),
|
refineTask: (...args: any[]) => mockRefineTask(...args),
|
||||||
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
|
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
|
||||||
|
fetchTaskEffectiveSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
|
||||||
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
|
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||||
fetchModels: (...args: any[]) => mockFetchModels(...args),
|
fetchModels: (...args: any[]) => mockFetchModels(...args),
|
||||||
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
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();
|
expect(container.querySelector(".detail-body")).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -937,12 +939,12 @@ describe("PlanningModeModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Summary markdown preview toggle", () => {
|
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) => {
|
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
handlers.onSummary?.({
|
handlers.onSummary?.({
|
||||||
...mockSummary,
|
...mockSummary,
|
||||||
description: "## Heading\n\n- item\n\n**bold**",
|
description,
|
||||||
});
|
});
|
||||||
}, 10);
|
}, 10);
|
||||||
|
|
||||||
@@ -952,7 +954,7 @@ describe("PlanningModeModal", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const { container } = render(
|
const result = render(
|
||||||
<PlanningModeModal
|
<PlanningModeModal
|
||||||
isOpen={true}
|
isOpen={true}
|
||||||
onClose={mockOnClose}
|
onClose={mockOnClose}
|
||||||
@@ -971,6 +973,12 @@ describe("PlanningModeModal", () => {
|
|||||||
expect(screen.getByText("Planning Complete!")).toBeDefined();
|
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-textarea")).not.toBeNull();
|
||||||
expect(container.querySelector(".planning-description-preview")).toBeNull();
|
expect(container.querySelector(".planning-description-preview")).toBeNull();
|
||||||
|
|
||||||
@@ -988,6 +996,55 @@ describe("PlanningModeModal", () => {
|
|||||||
expect(container.querySelector(".planning-textarea")).not.toBeNull();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user