diff --git a/packages/dashboard/app/components/PlanningModeModal.css b/packages/dashboard/app/components/PlanningModeModal.css index 7d84477ac6..17a6498c72 100644 --- a/packages/dashboard/app/components/PlanningModeModal.css +++ b/packages/dashboard/app/components/PlanningModeModal.css @@ -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); } } diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index 77657168f3..46e6fd3c6a 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -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(null); const commentInputRef = useRef(null); + const commentEditorRef = useRef(null); const addCommentTriggerRef = useRef(null); const mobileAddCommentTriggerRef = useRef(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 )} {isCommentEditorOpen && selectedPlanQuote && ( -
+

{selectedPlanQuote}