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:
gsxdsm
2026-07-23 17:56:05 -07:00
parent f8e6fd5e2b
commit 45da5f71d3
5 changed files with 90 additions and 33 deletions

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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", () => {

View File

@@ -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*;/);
});
});

View File

@@ -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(() => {