From 27097ebdd67a35486f20fb9c26a2399a60025a43 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 5 Sep 2026 06:14:15 -0700 Subject: [PATCH] refactor(web): give composer layout one owner --- apps/web/src/components/ChatView.tsx | 469 ++++++-------- apps/web/src/components/chat/ChatComposer.tsx | 477 +------------- .../components/chat/ComposerFrame.test.tsx | 373 +++++++++++ .../web/src/components/chat/ComposerFrame.tsx | 588 ++++++++++++++++++ 4 files changed, 1165 insertions(+), 742 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerFrame.test.tsx create mode 100644 apps/web/src/components/chat/ComposerFrame.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c0c79c93bec1..3f1e61140093 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -314,7 +314,7 @@ import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; -import { resolveComposerTimelineInset } from "./composerFooterLayout"; +import type { ComposerFrameLayout } from "./chat/ComposerFrame"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; @@ -345,7 +345,6 @@ import { useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; -import { ComposerSurface } from "./chat/ComposerSurface"; import { hasAvailableCompactionProvider, hasDismissedResumeCompaction, @@ -1652,16 +1651,13 @@ export default function ChatView(props: ChatViewProps) { () => legendListRef.current?.getScrollableNode() ?? null, [], ); - const [composerOverlayElement, setComposerOverlayElement] = useState(null); - const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); - const composerOverlayHeightRef = useRef(0); - // Space the timeline keeps clear above its end. Tracks the overlay while the - // composer is expanded and holds that height while it rests, so the resting - // composer never exposes rows that its expansion will cover. - const [composerTimelineInset, setComposerTimelineInset] = useState(0); - const composerTimelineInsetRef = useRef(0); - const composerRestingRef = useRef(false); - const [scrollToEndClearance, setScrollToEndClearance] = useState(0); + const [composerLayout, setComposerLayout] = useState({ + mode: "expanded", + visibleHeight: 0, + reservedHeight: 0, + }); + const { visibleHeight: composerOverlayHeight, reservedHeight: composerTimelineInset } = + composerLayout; const isAtEndRef = useRef(true); const isTimelineAtLogicalEnd = useCallback(() => isAtEndRef.current, []); // Whether the timeline's rows extend past the viewport above the composer. @@ -4767,10 +4763,11 @@ export default function ChatView(props: ChatViewProps) { cancelTimelineLiveFollowForUserNavigation(); } }); + const readComposerVisibleHeight = useEffectEvent(() => composerOverlayHeight); useEffect(() => { const controller = createPageScrollController({ getContainer: () => legendListRef.current?.getScrollableNode() ?? null, - getScrollPaddingBottomPx: () => composerOverlayElement?.getBoundingClientRect().height ?? 0, + getScrollPaddingBottomPx: readComposerVisibleHeight, onScrollStart: handlePageScrollStart, }); pageScrollControllerRef.current = controller; @@ -4781,7 +4778,7 @@ export default function ChatView(props: ChatViewProps) { pageScrollControllerRef.current = null; } }; - }, [composerOverlayElement]); + }, []); const onComposerPageScrollKeyDown = useCallback((key: PageScrollKey) => { pageScrollControllerRef.current?.handleKeyDown(key); }, []); @@ -5225,60 +5222,6 @@ export default function ChatView(props: ChatViewProps) { ? activePlan.steps : null; - const publishComposerOverlayHeight = useCallback((height: number) => { - const nextHeight = Math.ceil(height); - if (nextHeight <= 0) return; - const previousHeight = composerOverlayHeightRef.current; - if (previousHeight !== nextHeight) { - composerOverlayHeightRef.current = nextHeight; - setComposerOverlayHeight(nextHeight); - } - const nextInset = resolveComposerTimelineInset({ - currentInset: composerTimelineInsetRef.current, - overlayHeight: nextHeight, - isResting: composerRestingRef.current, - }); - if (composerTimelineInsetRef.current !== nextInset) { - composerTimelineInsetRef.current = nextInset; - setComposerTimelineInset(nextInset); - } - setScrollToEndClearance((currentClearance) => - currentClearance === nextHeight ? currentClearance : nextHeight, - ); - }, []); - // The composer reports its resting flag from a layout effect, which runs - // before this component's own layout effects and before any resize - // observation, so every measurement below sees the flag for its layout. - // Only the flag is stored here: the stored height still belongs to the - // previous layout, and the composer publishes the new layout's height - // itself once it has measured it. - const onComposerRestingChange = useCallback((resting: boolean) => { - composerRestingRef.current = resting; - }, []); - // A held reservation belongs to the previous thread's draft. Rebuild it from - // this thread's overlay so a tall draft elsewhere does not pad this one. - useLayoutEffect(() => { - if (!composerOverlayElement) return; - composerTimelineInsetRef.current = 0; - publishComposerOverlayHeight(composerOverlayElement.getBoundingClientRect().height); - }, [activeThreadKey, composerOverlayElement, publishComposerOverlayHeight]); - - useLayoutEffect(() => { - if (!composerOverlayElement) return; - - const updateHeight = () => { - publishComposerOverlayHeight(composerOverlayElement.getBoundingClientRect().height); - }; - - updateHeight(); - if (typeof ResizeObserver === "undefined") return; - - const resizeObserver = new ResizeObserver(updateHeight); - resizeObserver.observe(composerOverlayElement); - return () => { - resizeObserver.disconnect(); - }; - }, [composerOverlayElement, publishComposerOverlayHeight]); const activeThreadPr = replacementLinkedThreadPullRequest !== null ? (gitStatusQuery.data?.pr ?? null) @@ -8141,7 +8084,7 @@ export default function ChatView(props: ChatViewProps) { {showScrollToBottom && (