fix(dashboard): keep mobile plan-review selection comments on-screen

Plan review Add-comment controls now track document-level selectionchange so
they appear as soon as text is selected and dismiss when the selection ends.
On mobile the trigger and composer are fixed above the nav (with width auto)
so operators no longer need to scroll to reach them.
This commit is contained in:
gsxdsm
2026-07-23 17:19:26 -07:00
parent dfb9ca6630
commit 62c52972fc
7 changed files with 310 additions and 60 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep Planning plan-review Add-comment controls on-screen on mobile after text selection.
category: fix
dev: Selectioncapture uses document-level selectionchange; mobile trigger and composer are position:fixed above the nav with width auto so they stay in the visual viewport and dismiss when the selection collapses.

View File

@@ -1794,6 +1794,13 @@ its established Refine and Proceed action hierarchy.
background: var(--card);
}
/*
FNXC:PlanningComments 2026-07-23-17:05:
Desktop (≥769px) keeps the in-document trigger under the plan markdown. Mobile (≤768px) lifts
the counterpart into a fixed bottom bar in the visual viewport so it never requires scrolling
the plan document or action rail after a touch selection.
*/
.planning-comment-quote,
.planning-comment-tray blockquote,
.planning-comment-tray p {
@@ -1886,26 +1893,75 @@ plan actions, and a token-sized bottom inset keep all three controls inline with
@media (max-width: 768px) {
/*
FNXC:PlanningComments 2026-07-31-00:00:
A selected quote needs a reachable mobile action beside Refine and Proceed, not a control lost
in the scrollable document. At 769px and above the selection-adjacent document trigger stays
canonical. These established 768px/1024px media boundaries are permitted; new dimensions use
only existing design tokens.
A selected quote needs a reachable mobile action, not a control lost in the scrollable
document. At 769px and above the selection-adjacent document trigger stays canonical.
FNXC:PlanningComments 2026-07-23-17:05:
The mobile trigger is position:fixed to the selection midpoint and clamped into the visual
viewport (including safe-area + mobile-nav clearance) so it always appears after a selection
and can be dismissed by selectionchange when the selection collapses — without scrolling the
screen. These established 768px/1024px media boundaries are permitted; new dimensions use only
existing design tokens.
*/
.planning-add-comment--document {
display: none;
}
.planning-add-comment--mobile {
display: inline-flex;
/*
FNXC:PlanningComments 2026-07-23-17:05:
Pin the mobile Add-comment control to the visual viewport above the mobile nav/safe-area.
A fixed bar appears immediately after selectionchange and dismisses when the selection
collapses — no document or action-rail scroll required.
The mobile plan-actions button rule forces width 100 percent. With position fixed that 100
percent is the viewport width, and combined with left/right insets the control overflowed past
the right edge (measured 780px wide in a 768px viewport). Use a higher-specificity selector and
width auto so left+right define the used width and the bar stays fully on-screen.
*/
.planning-plan-actions .btn.planning-add-comment--mobile {
display: flex;
position: fixed;
left: var(--space-md);
right: var(--space-md);
bottom: calc(
var(--mobile-nav-height, 44px)
+ max(env(safe-area-inset-bottom, 0px), 12px)
+ var(--space-md)
);
z-index: var(--z-popover);
width: auto;
max-width: none;
margin-top: 0;
justify-content: center;
box-shadow: var(--shadow-md);
}
.planning-comment-tray li {
grid-template-columns: minmax(0, 1fr) auto;
}
/*
FNXC:PlanningComments 2026-07-23-17:05:
The comment composer used to flow at the end of the plan markdown, so opening it after a mid-
document selection required scrolling to the document foot. Pin it to the visual viewport above
the mobile nav/safe-area (and above the selection trigger slot) so Cancel / Add comment stay
reachable without scrolling.
*/
.planning-comment-editor {
position: fixed;
left: var(--space-md);
right: var(--space-md);
bottom: calc(
var(--mobile-nav-height, 44px)
+ max(env(safe-area-inset-bottom, 0px), 12px)
+ var(--space-md)
);
z-index: calc(var(--z-popover) + 1);
margin-top: 0;
max-height: min(50dvh, calc(var(--space-2xl) * 12));
overflow: auto;
padding: var(--space-md);
box-shadow: var(--shadow-lg);
}
}

View File

@@ -678,21 +678,36 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const addCommentTriggerRef = useRef<HTMLButtonElement>(null);
const mobileAddCommentTriggerRef = useRef<HTMLButtonElement>(null);
const restoreCommentTriggerFocusRef = useRef(false);
const isCommentEditorOpenRef = useRef(false);
const setCommentEditorOpen = useCallback((open: boolean) => {
/*
FNXC:PlanningComments 2026-07-23-17:05:
Flip the lock ref synchronously with the state write. Waiting for useEffect left a window
where focusing the suggestion field emitted selectionchange while the ref was still false,
which cleared the quote and unmounted the composer before the operator could type.
*/
isCommentEditorOpenRef.current = open;
setIsCommentEditorOpen(open);
}, []);
const focusAddCommentTrigger = useCallback(() => {
const isMobile = window.matchMedia?.("(max-width: 768px)").matches ?? false;
(isMobile ? mobileAddCommentTriggerRef : addCommentTriggerRef).current?.focus();
const isMobileViewport = window.matchMedia?.("(max-width: 768px)").matches ?? false;
(isMobileViewport ? mobileAddCommentTriggerRef : addCommentTriggerRef).current?.focus();
}, []);
/*
FNXC:PlanningComments 2026-07-23-09:30:
Closing the conditional comment editor unmounts its trigger during the state transition. Restore focus only in the post-render effect, after Cancel or Add comment remounts the desktop or mobile trigger.
Closing the conditional comment editor unmounts its trigger during the state transition. Restore focus only in the post-render effect when Cancel leaves a live plan selection and remounts the trigger.
*/
useEffect(() => {
if (!isCommentEditorOpen && restoreCommentTriggerFocusRef.current) {
restoreCommentTriggerFocusRef.current = false;
focusAddCommentTrigger();
}
if (isCommentEditorOpen || !restoreCommentTriggerFocusRef.current) return;
restoreCommentTriggerFocusRef.current = false;
queueMicrotask(() => {
if (addCommentTriggerRef.current || mobileAddCommentTriggerRef.current) {
focusAddCommentTrigger();
}
});
}, [focusAddCommentTrigger, isCommentEditorOpen]);
useEffect(() => {
@@ -700,11 +715,18 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setContextualComments([]);
setSelectedPlanQuote(null);
setCommentDraft("");
setIsCommentEditorOpen(false);
}, [selectedSessionId]);
setCommentEditorOpen(false);
}, [selectedSessionId, setCommentEditorOpen]);
useEffect(() => {
if (isCommentEditorOpen) commentInputRef.current?.focus();
if (isCommentEditorOpen) {
/*
FNXC:PlanningComments 2026-07-23-17:05:
The mobile composer is position:fixed; preventScroll keeps the plan markdown under the
selection instead of scrolling the document to the editor's former in-flow slot.
*/
commentInputRef.current?.focus({ preventScroll: true });
}
}, [isCommentEditorOpen]);
useEffect(() => {
@@ -2649,28 +2671,76 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}
}, [connectToPlanningStream, projectId, refinementInstructions, t, view, workspaceQuestion]);
/*
FNXC:PlanningComments 2026-07-23-17:05:
Plan selection must track document-level selectionchange (not only mouseup/touchend on the
markdown root). On mobile, the selection often finalizes after touchend, so waiting on the
element handlers left the Add-comment control missing until a later scroll/gesture. Collapsed
or out-of-plan selections clear the quote so the control dismisses when the selection is done.
While the comment editor is open the quote stays locked — opening the editor collapses the
native selection, and clearing here would unmount the composer mid-edit.
*/
const capturePlanSelection = useCallback(() => {
if (isCommentEditorOpenRef.current) return;
const selection = window.getSelection();
const root = planDocumentRef.current;
if (!selection || selection.rangeCount === 0 || !root || !root.contains(selection.anchorNode) || !root.contains(selection.focusNode)) {
if (
!selection
|| selection.rangeCount === 0
|| selection.isCollapsed
|| !root
|| !root.contains(selection.anchorNode)
|| !root.contains(selection.focusNode)
) {
setSelectedPlanQuote(null);
return;
}
const quote = selection.toString().replace(/\s+/g, " ").trim();
setSelectedPlanQuote(quote || null);
}, []);
useEffect(() => {
document.addEventListener("selectionchange", capturePlanSelection);
document.addEventListener("mouseup", capturePlanSelection);
document.addEventListener("touchend", capturePlanSelection);
document.addEventListener("keyup", capturePlanSelection);
return () => {
document.removeEventListener("selectionchange", capturePlanSelection);
document.removeEventListener("mouseup", capturePlanSelection);
document.removeEventListener("touchend", capturePlanSelection);
document.removeEventListener("keyup", capturePlanSelection);
};
}, [capturePlanSelection]);
useEffect(() => {
/*
FNXC:PlanningComments 2026-07-23-17:05:
Composer focus collapses the native selection. When the editor closes, re-sync so a done
selection dismisses the Add-comment control instead of leaving a sticky orphaned quote.
*/
if (!isCommentEditorOpen) {
capturePlanSelection();
}
}, [capturePlanSelection, isCommentEditorOpen]);
const handleAddContextualComment = useCallback(() => {
const quote = selectedPlanQuote;
const suggestion = commentDraft.trim();
if (!quote || !suggestion) return;
setContextualComments((comments) => [...comments, { quote, suggestion }]);
setCommentDraft("");
// FNXC:PlanningComments 2026-07-23-09:30: Keep the quote while closing the editor so its conditional trigger remounts and can receive restored focus after adding a comment.
restoreCommentTriggerFocusRef.current = true;
setIsCommentEditorOpen(false);
/*
FNXC:PlanningComments 2026-07-23-17:05:
Adding a comment clears the native selection, so the quote must dismiss with it. Do not restore
focus onto a remounted trigger — that path only applies when Cancel keeps an active selection.
*/
restoreCommentTriggerFocusRef.current = false;
setCommentEditorOpen(false);
setSelectedPlanQuote(null);
window.getSelection()?.removeAllRanges();
}, [commentDraft, selectedPlanQuote]);
}, [commentDraft, selectedPlanQuote, setCommentEditorOpen]);
const handleSubmitContextualComments = useCallback(async () => {
const sessionId = currentSessionIdRef.current;
@@ -2941,13 +3011,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
Contextual quotes must originate exclusively in rendered plan Markdown. The editor and
action controls remain outside this selection root so typing or selecting a suggestion
cannot replace the captured plan quote.
FNXC:PlanningComments 2026-07-23-17:05:
Selection capture is document-level (selectionchange/mouseup/touchend/keyup). This root
only scopes which nodes count as plan text — handlers are not required on the element.
*/}
<div
ref={planDocumentRef}
onMouseUp={capturePlanSelection}
onTouchEnd={capturePlanSelection}
onKeyUp={capturePlanSelection}
>
<div ref={planDocumentRef} data-testid="planning-plan-selection-root">
<MailboxMessageContent
className="planning-plan-markdown markdown-body"
content={formatPlanningPlanMd(summary)}
@@ -2959,7 +3028,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
ref={addCommentTriggerRef}
type="button"
className="btn planning-add-comment planning-add-comment--document"
onClick={() => setIsCommentEditorOpen(true)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => setCommentEditorOpen(true)}
>
<MessageSquarePlus />
{t("planning.addComment", "Add comment to selection")}
@@ -2973,7 +3043,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
<textarea ref={commentInputRef} className="input" value={commentDraft} onChange={(event) => setCommentDraft(event.target.value)} />
</label>
<div className="planning-refine-menu-actions">
<button type="button" className="btn" onClick={() => { setCommentDraft(""); restoreCommentTriggerFocusRef.current = true; setIsCommentEditorOpen(false); }}>{t("common.cancel", "Cancel")}</button>
<button type="button" className="btn" onClick={() => { setCommentDraft(""); restoreCommentTriggerFocusRef.current = true; setCommentEditorOpen(false); }}>{t("common.cancel", "Cancel")}</button>
<button type="button" className="btn btn-primary" disabled={!commentDraft.trim()} onClick={handleAddContextualComment}>{t("planning.addComment", "Add comment")}</button>
</div>
</div>
@@ -2983,17 +3053,23 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
<div className="planning-actions planning-summary-actions planning-plan-actions" data-testid="planning-plan-actions">
{/*
FNXC:PlanningComments 2026-07-31-00:00:
FN-8533 keeps the selection-adjacent control at 769px and wider, but mobile's reachable
action rail owns its counterpart. The two variants share the same editor transition and
CSS makes exactly one visible/focusable; only established 768px/1024px breakpoint literals
are allowed here, while all other dimensions remain design-token based.
FN-8533 keeps the selection-adjacent control at 769px and wider, but mobile needs a
counterpart that cannot be lost under the document fold.
FNXC:PlanningComments 2026-07-23-17:05:
On ≤768px the mobile trigger is position:fixed to the visual viewport above the mobile
nav so it appears immediately after a selection without scrolling, and document-level
selectionchange dismisses it when the selection collapses. CSS still shows exactly one
of the two variants; only established 768px/1024px breakpoint literals are allowed here,
while all other dimensions remain design-token based.
*/}
{selectedPlanQuote && !isCommentEditorOpen && (
<button
ref={mobileAddCommentTriggerRef}
type="button"
className="btn planning-add-comment planning-add-comment--mobile"
onClick={() => setIsCommentEditorOpen(true)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => setCommentEditorOpen(true)}
>
<MessageSquarePlus />
{t("planning.addComment", "Add comment to selection")}

View File

@@ -166,14 +166,21 @@ describe("PlanningModeModal CSS responsive action contract", () => {
expectSomeRule(tabletCss, ".planning-plan-actions", /flex-wrap\s*:\s*nowrap\s*;/);
});
it("shows exactly one contextual comment trigger in the mobile plan footer", () => {
it("shows exactly one contextual comment trigger fixed to the mobile selection viewport", () => {
const css = loadPlanningCss();
const mobileCss = getMediaBlocks(css, MOBILE_ACTIONS_QUERY).join("\n");
const mobileTriggerRule = findRule(mobileCss, ".planning-plan-actions .btn.planning-add-comment--mobile");
const mobileEditorRule = findRule(mobileCss, ".planning-comment-editor");
expect(findRule(css, ".planning-add-comment--mobile")).toMatch(/display\s*:\s*none\s*;/);
expect(findRule(mobileCss, ".planning-add-comment--document")).toMatch(/display\s*:\s*none\s*;/);
expect(findRule(mobileCss, ".planning-add-comment--mobile")).toMatch(/display\s*:\s*inline-flex\s*;/);
expect(findRule(mobileCss, ".planning-add-comment--mobile")).toMatch(/margin-top\s*:\s*0\s*;/);
expect(mobileTriggerRule).toMatch(/display\s*:\s*flex\s*;/);
expect(mobileTriggerRule).toMatch(/position\s*:\s*fixed\s*;/);
expect(mobileTriggerRule).toMatch(/width\s*:\s*auto\s*;/);
expect(mobileTriggerRule).toMatch(/var\(--mobile-nav-height/);
expect(mobileTriggerRule).toMatch(/margin-top\s*:\s*0\s*;/);
expect(mobileEditorRule).toMatch(/position\s*:\s*fixed\s*;/);
expect(mobileEditorRule).toMatch(/var\(--mobile-nav-height/);
});
it("pins only the plan-selection rail while its document scrolls in portrait and width-independent short landscape", () => {

View File

@@ -328,34 +328,51 @@ describe("PlanningModeModal sequential flow", () => {
renderSession();
const documentNode = await screen.findByTestId("planning-plan-markdown");
const selectQuote = (quote: string) => {
const walker = document.createTreeWalker(documentNode, NodeFilter.SHOW_TEXT);
let textNode: Node | null = walker.nextNode();
while (textNode && !textNode.textContent?.includes(quote)) textNode = walker.nextNode();
expect(textNode).not.toBeNull();
const range = document.createRange();
range.selectNodeContents(textNode!);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
fireEvent.mouseUp(documentNode);
act(() => {
const walker = document.createTreeWalker(documentNode, NodeFilter.SHOW_TEXT);
let textNode: Node | null = walker.nextNode();
while (textNode && !textNode.textContent?.includes(quote)) textNode = walker.nextNode();
expect(textNode).not.toBeNull();
const range = document.createRange();
range.selectNodeContents(textNode!);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
document.dispatchEvent(new Event("selectionchange"));
fireEvent.mouseUp(documentNode);
});
};
selectQuote("Build authentication system");
const actionBar = screen.getByTestId("planning-plan-actions");
const documentTrigger = document.querySelector(".planning-add-comment--document");
const mobileTrigger = document.querySelector(".planning-add-comment--mobile");
expect(documentTrigger?.closest(".planning-plan-document")).toContainElement(documentTrigger);
expect(actionBar).toContainElement(mobileTrigger);
expect(documentTrigger).toBeInstanceOf(HTMLElement);
expect(mobileTrigger).toBeInstanceOf(HTMLElement);
expect(documentTrigger?.closest(".planning-plan-document")).toContainElement(documentTrigger as HTMLElement);
expect(actionBar).toContainElement(mobileTrigger as HTMLElement);
fireEvent.click(screen.getByRole("button", { name: "Add comment to selection" }));
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(document.activeElement).toBe(document.querySelector(".planning-add-comment--document")));
/*
FNXC:PlanningComments 2026-07-23-17:05:
Opening the composer moves the native selection into the suggestion field. Cancel leaves
that selection collapsed, so the trigger dismisses with the selection instead of staying
sticky after the selection is done. Re-select to comment again.
*/
await waitFor(() => expect(screen.queryByRole("button", { name: "Add comment to selection" })).toBeNull());
fireEvent.click(screen.getByRole("button", { name: "Add comment to selection" }));
selectQuote("Build authentication system");
fireEvent.click(await screen.findByRole("button", { name: "Add comment to selection" }));
const suggestionInput = screen.getByLabelText("Suggestion");
fireEvent.change(suggestionInput, { target: { value: "Explain the audit path." } });
// Editor selections are not plan selections: the captured quote must remain the Markdown text.
suggestionInput.setSelectionRange(0, suggestionInput.value.length);
fireEvent.mouseUp(suggestionInput);
act(() => {
suggestionInput.setSelectionRange(0, suggestionInput.value.length);
fireEvent.mouseUp(suggestionInput);
document.dispatchEvent(new Event("selectionchange"));
});
expect(screen.getByLabelText("Add plan comment")).toHaveTextContent("Build authentication system");
fireEvent.click(screen.getByRole("button", { name: "Add comment" }));
await waitFor(() => expect(document.activeElement).toBe(document.querySelector(".planning-add-comment--document")));
// Adding a comment clears the selection, so the trigger dismisses with it.
await waitFor(() => expect(document.querySelector(".planning-add-comment")).toBeNull());
expect(screen.getByTestId("planning-comment-tray")).toHaveTextContent("Explain the audit path.");
expect(screen.getByRole("button", { name: "Refine" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Proceed with plan" })).toBeInTheDocument();
@@ -365,6 +382,36 @@ describe("PlanningModeModal sequential flow", () => {
}, "project-1"));
});
it("dismisses the selection comment trigger when the plan selection collapses", async () => {
mockFetchAiSession.mockResolvedValue({
...base,
status: "awaiting_input",
currentQuestion: JSON.stringify({ id: "q-1", type: "text", question: "Anything else?" }),
result: JSON.stringify(summaryWithRefinements),
inputPayload: "{}",
});
renderSession();
const documentNode = await screen.findByTestId("planning-plan-markdown");
const walker = document.createTreeWalker(documentNode, NodeFilter.SHOW_TEXT);
let textNode: Node | null = walker.nextNode();
while (textNode && !textNode.textContent?.includes("Build authentication system")) textNode = walker.nextNode();
expect(textNode).not.toBeNull();
act(() => {
const range = document.createRange();
range.selectNodeContents(textNode!);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
document.dispatchEvent(new Event("selectionchange"));
});
expect(await screen.findByRole("button", { name: "Add comment to selection" })).toBeInTheDocument();
act(() => {
window.getSelection()?.removeAllRanges();
document.dispatchEvent(new Event("selectionchange"));
});
await waitFor(() => expect(screen.queryByRole("button", { name: "Add comment to selection" })).toBeNull());
});
it("rehydrates a restored idle session when another tab advances its question", async () => {
mockFetchAiSession.mockResolvedValue({
...base,

View File

@@ -16,6 +16,8 @@ describe("PlanningModeModal sequential layout", () => {
expect(component).toContain("planDocumentRef.current");
expect(component).toContain("root.contains(selection.anchorNode)");
expect(component).toContain("root.contains(selection.focusNode)");
expect(component).toContain('document.addEventListener("selectionchange", capturePlanSelection)');
expect(component).toContain("selection.isCollapsed");
expect(component).toContain("Add comment to selection");
expect(component).toContain("planning-add-comment--document");
expect(component).toContain("planning-add-comment--mobile");
@@ -32,6 +34,8 @@ describe("PlanningModeModal sequential layout", () => {
expect(css).toMatch(/@media \(max-width: 1024px\)[\s\S]*?\.planning-plan-actions \.btn\s*\{[^}]*width\s*:\s*100%\s*;/);
expect(css).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.planning-plan-actions\s*\{[^}]*gap\s*:\s*var\(--space-md\)\s*;[^}]*calc\(var\(--space-sm\) \+ env\(safe-area-inset-bottom\)\)/);
expect(css).toMatch(/\.planning-add-comment--mobile\s*\{[^}]*display\s*:\s*none\s*;/);
expect(css).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.planning-add-comment--document\s*\{[^}]*display\s*:\s*none\s*;[\s\S]*?\.planning-add-comment--mobile\s*\{[^}]*display\s*:\s*inline-flex\s*;/);
expect(css).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.planning-add-comment--document\s*\{[^}]*display\s*:\s*none\s*;/);
expect(css).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.planning-plan-actions \.btn\.planning-add-comment--mobile\s*\{[^}]*display\s*:\s*flex\s*;[^}]*position\s*:\s*fixed\s*;[^}]*width\s*:\s*auto\s*;/);
expect(css).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.planning-comment-editor\s*\{[^}]*position\s*:\s*fixed\s*;/);
});
});

View File

@@ -229,16 +229,31 @@ describe.runIf(executablePath)("Planning Mode browser E2E", () => {
const focusable = [...document.querySelectorAll<HTMLElement>("button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])")]
.filter((element) => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== "hidden");
const triggerIndex = visibleButton ? focusable.indexOf(visibleButton) : -1;
const buttonRect = visibleButton?.getBoundingClientRect();
/*
FNXC:PlanningComments 2026-07-31-09:05:
Start at the preceding tab stop, then send actual Tab keys. Programmatic focus alone would
incorrectly accept a tabIndex=-1 contextual-comment trigger as keyboard reachable.
FNXC:PlanningComments 2026-07-23-17:05:
Mobile keeps the trigger in the actions DOM for focus order, but CSS fixes it to the
selection so it is inside the visual viewport without scrolling the plan document.
*/
focusable[triggerIndex - 1]?.focus();
return {
totalButtons: buttons.length,
visibleButtons: visibleButtons.length,
visibleInActions: Boolean(visibleButton && actions.contains(visibleButton)),
positionFixed: visibleButton ? getComputedStyle(visibleButton).position === "fixed" : false,
triggerInsideViewport: Boolean(
buttonRect
&& buttonRect.width > 0
&& buttonRect.height > 0
&& buttonRect.top >= 0
&& buttonRect.bottom <= window.innerHeight
&& buttonRect.left >= 0
&& buttonRect.right <= window.innerWidth,
),
triggerHasPreviousTabStop: triggerIndex > 0,
hiddenButtonsTabbable: buttons.filter((button) => button !== visibleButton && button.tabIndex >= 0 && getComputedStyle(button).display !== "none").length,
actionLabels: [...actions.querySelectorAll<HTMLButtonElement>("button")]
@@ -251,10 +266,19 @@ describe.runIf(executablePath)("Planning Mode browser E2E", () => {
totalButtons: 2,
visibleButtons: 1,
visibleInActions: inFooter,
positionFixed: inFooter,
// Mobile fixed-to-selection control must be in the visual viewport without scrolling.
triggerInsideViewport: inFooter ? true : expect.any(Boolean),
triggerHasPreviousTabStop: true,
hiddenButtonsTabbable: 0,
});
if (inFooter) expect(placement.actionLabels).toEqual(expect.arrayContaining(["Add comment to selection", "Refine", "Proceed with plan"]));
/*
FNXC:PlanningComments 2026-07-23-17:05:
The mobile trigger is position:fixed out of the action-grid flow, so it no longer appears in
the action-rail label list — only Refine/Proceed remain there while the floating control is
separately asserted as the sole visible Add-comment button.
*/
if (inFooter) expect(placement.actionLabels).toEqual(expect.arrayContaining(["Refine", "Proceed with plan"]));
else expect(placement.actionLabels).not.toContain("Add comment to selection");
let reachedTriggerByTab = false;
@@ -269,11 +293,40 @@ describe.runIf(executablePath)("Planning Mode browser E2E", () => {
await page.getByRole("button", { name: "Add comment to selection" }).click();
await expectVisible(page.getByLabel("Add plan comment"));
if (inFooter && presentation === "embedded" && process.env.FUSION_CAPTURE_DIR) await page.screenshot({ path: `${process.env.FUSION_CAPTURE_DIR}/planning-comment-mobile-editor.png` });
const afterOpen = await page.evaluate(() => ({
addCommentButtons: document.querySelectorAll(".planning-add-comment").length,
actionChildren: document.querySelector("[data-testid='planning-plan-actions']")?.querySelectorAll(".planning-add-comment").length,
}));
expect(afterOpen).toEqual({ addCommentButtons: 0, actionChildren: 0 });
const afterOpen = await page.evaluate(() => {
const editor = document.querySelector<HTMLElement>(".planning-comment-editor");
const editorRect = editor?.getBoundingClientRect();
return {
addCommentButtons: document.querySelectorAll(".planning-add-comment").length,
actionChildren: document.querySelector("[data-testid='planning-plan-actions']")?.querySelectorAll(".planning-add-comment").length,
editorPosition: editor ? getComputedStyle(editor).position : null,
editorInsideViewport: Boolean(
editorRect
&& editorRect.width > 0
&& editorRect.height > 0
&& editorRect.top >= 0
&& editorRect.bottom <= window.innerHeight,
),
};
});
expect(afterOpen).toMatchObject({
addCommentButtons: 0,
actionChildren: 0,
editorPosition: inFooter ? "fixed" : "static",
// Mobile pins the composer into the viewport; desktop keeps the in-document editor.
editorInsideViewport: inFooter ? true : expect.any(Boolean),
});
await page.evaluate(() => {
window.getSelection()?.removeAllRanges();
document.dispatchEvent(new Event("selectionchange"));
});
// Editor stays locked open while composing even if the native selection collapses.
await expectVisible(page.getByLabel("Add plan comment"));
await page.getByRole("button", { name: "Cancel" }).click();
// After cancel with a collapsed selection the trigger must dismiss.
await page.waitForTimeout(50);
expect(await page.getByRole("button", { name: "Add comment to selection" }).isVisible()).toBe(false);
await page.close();
}