diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 740fae8f8..958ce136a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -557,6 +557,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerEditorRef = useRef(null); const composerFormRef = useRef(null); const composerFormHeightRef = useRef(0); + const composerFooterRef = useRef(null); + const composerFooterLeadingRef = useRef(null); + const composerFooterActionsRef = useRef(null); const composerAttachmentsRef = useRef([]); const composerSelectLockRef = useRef(false); const composerMenuOpenRef = useRef(false); @@ -2239,23 +2242,38 @@ export default function ChatView({ threadId }: ChatViewProps) { useLayoutEffect(() => { const composerForm = composerFormRef.current; if (!composerForm) return; - const measureComposerFormWidth = () => composerForm.clientWidth; + const measureComposerFooterWidth = () => { + const footer = composerFooterRef.current; + if (!footer) return composerForm.clientWidth; + const footerStyle = window.getComputedStyle(footer); + const horizontalPadding = + Number.parseFloat(footerStyle.paddingLeft || "0") + + Number.parseFloat(footerStyle.paddingRight || "0"); + return Math.max(0, footer.clientWidth - horizontalPadding); + }; + const measureComposerFooterGap = () => { + const footer = composerFooterRef.current; + if (!footer) return 0; + const footerStyle = window.getComputedStyle(footer); + return Number.parseFloat(footerStyle.columnGap || footerStyle.gap || "0") || 0; + }; + const measureIsComposerFooterCompact = () => + shouldUseCompactComposerFooter(measureComposerFooterWidth(), { + hasWideActions: composerFooterHasWideActions, + leadingWidth: composerFooterLeadingRef.current?.scrollWidth ?? null, + trailingWidth: composerFooterActionsRef.current?.scrollWidth ?? null, + gap: measureComposerFooterGap(), + }); composerFormHeightRef.current = composerForm.getBoundingClientRect().height; - setIsComposerFooterCompact( - shouldUseCompactComposerFooter(measureComposerFormWidth(), { - hasWideActions: composerFooterHasWideActions, - }), - ); + setIsComposerFooterCompact(measureIsComposerFooterCompact()); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver((entries) => { const [entry] = entries; if (!entry) return; - const nextCompact = shouldUseCompactComposerFooter(measureComposerFormWidth(), { - hasWideActions: composerFooterHasWideActions, - }); + const nextCompact = measureIsComposerFooterCompact(); setIsComposerFooterCompact((previous) => (previous === nextCompact ? previous : nextCompact)); const nextHeight = entry.contentRect.height; @@ -2268,10 +2286,31 @@ export default function ChatView({ threadId }: ChatViewProps) { }); observer.observe(composerForm); + const composerFooter = composerFooterRef.current; + if (composerFooter) observer.observe(composerFooter); + const composerFooterLeading = composerFooterLeadingRef.current; + if (composerFooterLeading) observer.observe(composerFooterLeading); + const composerFooterActions = composerFooterActionsRef.current; + if (composerFooterActions) observer.observe(composerFooterActions); return () => { observer.disconnect(); }; - }, [activeThread?.id, composerFooterHasWideActions, scheduleStickToBottom]); + }, [ + activeContextWindow, + activePlan, + activeThread?.id, + composerFooterHasWideActions, + interactionMode, + lockedProvider, + phase, + planSidebarOpen, + queuedMessages.length, + runtimeMode, + scheduleStickToBottom, + selectedModelForPickerWithCustomFallback, + selectedProvider, + sidebarProposedPlan, + ]); useEffect(() => { if (!shouldAutoScrollRef.current) return; scheduleStickToBottom(); @@ -5034,6 +5073,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ) : (
{pendingUserInputs.length === 0 && ( diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts index 717e2623d..d15738692 100644 --- a/apps/web/src/components/composerFooterLayout.test.ts +++ b/apps/web/src/components/composerFooterLayout.test.ts @@ -32,4 +32,21 @@ describe("shouldUseCompactComposerFooter", () => { }), ).toBe(false); }); + + it("switches to compact mode when the measured footer content no longer fits", () => { + expect( + shouldUseCompactComposerFooter(359, { + leadingWidth: 180, + trailingWidth: 160, + gap: 8, + }), + ).toBe(true); + expect( + shouldUseCompactComposerFooter(360, { + leadingWidth: 180, + trailingWidth: 160, + gap: 8, + }), + ).toBe(false); + }); }); diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index 3cf994fc1..b66aa575f 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -1,10 +1,31 @@ export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620; export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 720; +const COMPOSER_FOOTER_CONTENT_BUFFER_PX = 12; export function shouldUseCompactComposerFooter( width: number | null, - options?: { hasWideActions?: boolean }, + options?: { + hasWideActions?: boolean; + leadingWidth?: number | null; + trailingWidth?: number | null; + gap?: number | null; + }, ): boolean { + if ( + width !== null && + typeof options?.leadingWidth === "number" && + Number.isFinite(options.leadingWidth) && + typeof options.trailingWidth === "number" && + Number.isFinite(options.trailingWidth) + ) { + const requiredWidth = + options.leadingWidth + + options.trailingWidth + + Math.max(0, options.gap ?? 0) + + COMPOSER_FOOTER_CONTENT_BUFFER_PX; + return width < requiredWidth; + } + const breakpoint = options?.hasWideActions ? COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX : COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX;