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:
7
.changeset/fix-planning-mobile-selection-comment.md
Normal file
7
.changeset/fix-planning-mobile-selection-comment.md
Normal 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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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*;/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user