fix(dashboard): keep planning comment composer above the keyboard
Pin the selection comment editor as a fixed panel on tablet and phone, and lift it with visualViewport keyboard metrics so the first focus no longer hides the form under the OS keyboard (or off-screen on tablet).
This commit is contained in:
@@ -1909,6 +1909,26 @@ reach past Proceed — match the tablet in-flow stack on phone too.
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PlanningComments 2026-07-24-06:05:
|
||||
Tablet and phone pin the selection composer as a fixed panel. Tablet previously left it in
|
||||
the plan document foot (keyboard opened while the panel stayed off-screen). Phone previously
|
||||
fixed above the nav only and did not lift for the first keyboard open. Base bottom clears
|
||||
chrome; .planning-comment-editor--keyboard-open / inline style lifts above the soft keyboard.
|
||||
*/
|
||||
.planning-comment-editor {
|
||||
position: fixed;
|
||||
left: var(--space-md);
|
||||
right: var(--space-md);
|
||||
bottom: calc(var(--space-md) + env(safe-area-inset-bottom, 0px));
|
||||
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);
|
||||
}
|
||||
|
||||
.planning-refine-menu {
|
||||
right: var(--space-lg);
|
||||
left: var(--space-lg);
|
||||
@@ -1928,29 +1948,22 @@ plan actions, and a token-sized bottom inset keep all three controls inline with
|
||||
@media (max-width: 768px) {
|
||||
/*
|
||||
FNXC:PlanningComments 2026-07-24-05:50:
|
||||
Phone keeps the ≤1024 in-flow full-width Add-comment row above Refine/Proceed (no fixed bar
|
||||
under the action rail). The composer still pins above the mobile nav so opening it after a mid-
|
||||
document selection does not require scrolling to the plan foot.
|
||||
Phone keeps the ≤1024 in-flow full-width Add-comment row above Refine/Proceed.
|
||||
|
||||
FNXC:PlanningComments 2026-07-24-06:05:
|
||||
When the keyboard is closed, clear the bottom nav; keyboard-open uses the shared lift style
|
||||
from the TS keyboard metrics so the panel is not trapped under the first OS keyboard frame.
|
||||
*/
|
||||
.planning-comment-tray li {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.planning-comment-editor {
|
||||
position: fixed;
|
||||
left: var(--space-md);
|
||||
right: var(--space-md);
|
||||
.planning-comment-editor:not(.planning-comment-editor--keyboard-open) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "./PlanningModeModal.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useState, useCallback, useEffect, useRef, useMemo, type MouseEvent, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { useState, useCallback, useEffect, useRef, useMemo, type CSSProperties, type MouseEvent, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Task, PlanningQuestion, PlanningSummary, TaskPriority, ThinkingLevel } from "@fusion/core";
|
||||
@@ -717,6 +717,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
const contextualCommentSubmissionRef = useRef(false);
|
||||
const planDocumentRef = useRef<HTMLDivElement>(null);
|
||||
const commentInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const commentEditorRef = useRef<HTMLDivElement>(null);
|
||||
const addCommentTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const mobileAddCommentTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const restoreCommentTriggerFocusRef = useRef(false);
|
||||
@@ -765,17 +766,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
setCommentEditorOpen(false);
|
||||
}, [selectedSessionId, setCommentEditorOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
if (isMobile && workspaceQuestion) {
|
||||
setMobileWorkspaceTab("question");
|
||||
@@ -885,10 +875,56 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
persistSidebarWidth(nextWidth);
|
||||
}, [isMobile, persistSidebarWidth, sidebarWidth]);
|
||||
|
||||
/*
|
||||
FNXC:PlanningComments 2026-07-24-06:05:
|
||||
Track the soft keyboard on phone always, and on tablet while the selection comment composer
|
||||
is open. Tablet is wider than isMobileDevice()'s 768 gate, so allowNonMobileViewport is
|
||||
required or the first focus would open the OS keyboard with the composer still off-screen
|
||||
(in-document at the plan foot) and never lift it.
|
||||
*/
|
||||
const commentComposerNeedsKeyboardLift = isCommentEditorOpen && viewportMode !== "desktop";
|
||||
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } =
|
||||
useMobileKeyboard({ enabled: viewportMode === "mobile" });
|
||||
useMobileKeyboard({
|
||||
enabled: isOpen && (viewportMode === "mobile" || commentComposerNeedsKeyboardLift),
|
||||
allowNonMobileViewport: commentComposerNeedsKeyboardLift && viewportMode === "tablet",
|
||||
});
|
||||
useMobileScrollLock(viewportMode === "mobile" && isOpen && scrollLockEnabled);
|
||||
|
||||
const commentEditorStyle = useMemo((): CSSProperties | undefined => {
|
||||
if (!commentComposerNeedsKeyboardLift) return undefined;
|
||||
/*
|
||||
FNXC:PlanningComments 2026-07-24-06:05:
|
||||
When the keyboard is open, pin the composer to the visual viewport above the keyboard.
|
||||
First open used to keep the phone fixed bottom (nav-only) so the panel sat under the
|
||||
keyboard until a second focus; drive bottom/max-height from live metrics instead.
|
||||
*/
|
||||
if (!keyboardOpen) return undefined;
|
||||
const bottomPx = Math.max(keyboardOverlap, 0);
|
||||
const style: CSSProperties = {
|
||||
bottom: `calc(${bottomPx}px + var(--space-md))`,
|
||||
};
|
||||
if (viewportHeight != null && viewportHeight > 0) {
|
||||
style.maxHeight = `min(${Math.round(viewportHeight * 0.55)}px, calc(var(--space-2xl) * 12))`;
|
||||
}
|
||||
return style;
|
||||
}, [commentComposerNeedsKeyboardLift, keyboardOpen, keyboardOverlap, viewportHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCommentEditorOpen) return;
|
||||
/*
|
||||
FNXC:PlanningComments 2026-07-23-17:05:
|
||||
The compact composer is position:fixed; preventScroll keeps the plan markdown under the
|
||||
selection instead of scrolling to the editor's former in-flow slot.
|
||||
|
||||
FNXC:PlanningComments 2026-07-24-06:05:
|
||||
Focus after paint so the fixed tablet/phone panel is mounted before the keyboard targets it.
|
||||
*/
|
||||
const frame = requestAnimationFrame(() => {
|
||||
commentInputRef.current?.focus({ preventScroll: true });
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [isCommentEditorOpen]);
|
||||
|
||||
// Drive --vv-height / --keyboard-overlap / --vv-offset-top imperatively
|
||||
// rather than via React's style prop. Reason: when React removes a CSS
|
||||
// custom property between renders it sets it to empty string instead of
|
||||
@@ -3135,7 +3171,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
</button>
|
||||
)}
|
||||
{isCommentEditorOpen && selectedPlanQuote && (
|
||||
<div className="planning-comment-editor" role="dialog" aria-label={t("planning.addPlanComment", "Add plan comment")}>
|
||||
<div
|
||||
ref={commentEditorRef}
|
||||
className={`planning-comment-editor${keyboardOpen && commentComposerNeedsKeyboardLift ? " planning-comment-editor--keyboard-open" : ""}`}
|
||||
style={commentEditorStyle}
|
||||
role="dialog"
|
||||
aria-label={t("planning.addPlanComment", "Add plan comment")}
|
||||
>
|
||||
<p className="planning-comment-quote">{selectedPlanQuote}</p>
|
||||
<label className="planning-refine-menu-input">
|
||||
<span>{t("planning.commentSuggestion", "Suggestion")}</span>
|
||||
|
||||
@@ -184,8 +184,10 @@ describe("PlanningModeModal CSS responsive action contract", () => {
|
||||
expect(findRule(compactCss, ".planning-plan-actions .btn.planning-add-comment--mobile svg")).toMatch(/width\s*:\s*var\(--space-lg\)\s*;/);
|
||||
// FNXC:PlanningComments 2026-07-24-05:50: phone no longer overrides the rail trigger to fixed.
|
||||
expect(findRule(mobileCss, ".planning-plan-actions .btn.planning-add-comment--mobile")).toBeUndefined();
|
||||
expect(mobileEditorRule).toMatch(/position\s*:\s*fixed\s*;/);
|
||||
expect(mobileEditorRule).toMatch(/var\(--mobile-nav-height/);
|
||||
// FNXC:PlanningComments 2026-07-24-06:05: tablet+phone pin the composer; phone clears nav when keyboard closed.
|
||||
expect(findRule(compactCss, ".planning-comment-editor")).toMatch(/position\s*:\s*fixed\s*;/);
|
||||
expect(findRule(mobileCss, ".planning-comment-editor:not(.planning-comment-editor--keyboard-open)")).toMatch(/var\(--mobile-nav-height/);
|
||||
expect(mobileEditorRule).toBeUndefined();
|
||||
});
|
||||
|
||||
it("pins only the plan-selection rail while its document scrolls in portrait and width-independent short landscape", () => {
|
||||
|
||||
@@ -37,6 +37,6 @@ describe("PlanningModeModal sequential layout", () => {
|
||||
expect(css).toMatch(/@media \(max-width: 1024px\)[\s\S]*?\.planning-add-comment--document\s*\{[^}]*display\s*:\s*none\s*;/);
|
||||
expect(css).toMatch(/@media \(max-width: 1024px\)[\s\S]*?\.planning-plan-actions \.btn\.planning-add-comment--mobile\s*\{[^}]*display\s*:\s*flex\s*;[^}]*grid-column\s*:\s*1\s*\/\s*-1\s*;/);
|
||||
expect(css).not.toMatch(/@media \(max-width: 768px\)[\s\S]*?\.planning-plan-actions \.btn\.planning-add-comment--mobile\s*\{[^}]*position\s*:\s*fixed\s*;/);
|
||||
expect(css).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.planning-comment-editor\s*\{[^}]*position\s*:\s*fixed\s*;/);
|
||||
expect(css).toMatch(/@media \(max-width: 1024px\)[\s\S]*?\.planning-comment-editor\s*\{[^}]*position\s*:\s*fixed\s*;/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -315,9 +315,9 @@ describe.runIf(executablePath)("Planning Mode browser E2E", () => {
|
||||
expect(afterOpen).toMatchObject({
|
||||
addCommentButtons: 0,
|
||||
actionChildren: 0,
|
||||
// Phone pins the composer when open; tablet/desktop keep the in-document editor.
|
||||
editorPosition: viewport.width <= 768 && inFooter ? "fixed" : "static",
|
||||
editorInsideViewport: viewport.width <= 768 && inFooter ? true : expect.any(Boolean),
|
||||
// Phone + tablet pin the composer; desktop keeps the in-document editor.
|
||||
editorPosition: inFooter ? "fixed" : "static",
|
||||
editorInsideViewport: inFooter ? true : expect.any(Boolean),
|
||||
});
|
||||
|
||||
await page.evaluate(() => {
|
||||
|
||||
Reference in New Issue
Block a user